Compare commits

..

241 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
Reinier Criel
782af8e789
Merge pull request #411 from AikidoSec/feat/dynamic-install-dir
Add support for custom install directory
2026-04-16 10:04:25 -07:00
Reinier Criel
b3372cc50e Rename function 2026-04-15 15:33:37 -07:00
Reinier Criel
7ed943d46f Fix Windows bash 2026-04-15 09:19:20 -07:00
Reinier Criel
a68cf97f89 One more fix 2026-04-14 16:14:05 -07:00
Reinier Criel
bafa997a70 Some fixes 2026-04-14 16:02:46 -07:00
Reinier Criel
cdb87792df Merge branch 'feat/dynamic-install-dir' of github.com:AikidoSec/safe-chain into feat/dynamic-install-dir 2026-04-14 13:24:38 -07:00
Reinier Criel
6ff2ee3367 Adapt per review 2026-04-14 11:30:29 -07:00
Reinier Criel
43fe715b08
Update install-scripts/install-safe-chain.sh
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-14 11:08:04 -07:00
Reinier Criel
0a9ab05468
Merge pull request #342 from stbenjam/uvx
[Python Improvements]: Add uvx support
2026-04-14 08:26:45 -07:00
Stephen Benjamin
8e4f036ce9 Add e2e test for UVX 2026-04-14 10:04:10 -04:00
Stephen Benjamin
14c8abffea Add uvx support
Add uvx as a supported package manager so that `uvx` commands are
routed through safe-chain's MITM proxy for malware detection, just
like `uv`. Previously, `uvx` bypassed all safe-chain protections.

The uvx package manager reuses the existing uv command runner since
uvx is functionally equivalent to `uv tool run`.

Fixes #268

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 10:04:10 -04:00
Reinier Criel
63b7a5ee5e Add better doc 2026-04-13 21:40:53 -07:00
Reinier Criel
f3ae77f12a Quality issue 2026-04-13 15:21:49 -07:00
Reinier Criel
7dd68cea12 Clean up readme 2026-04-13 15:10:52 -07:00
Reinier Criel
50623cfc9a Fix empty arg 2026-04-13 15:02:41 -07:00
Reinier Criel
e54869ddd0 Code Quality 2026-04-13 14:40:42 -07:00
Reinier Criel
1076d6bea8 Undo timeout change 2026-04-13 14:05:02 -07:00
Reinier Criel
8dbeab8dac Address code quality 2026-04-13 13:45:20 -07:00
Reinier Criel
38a8130f4a Some fixes 2026-04-13 13:32:55 -07:00
Reinier Criel
f7324ccfc0 Merge branch 'feat/dynamic-install-dir' of github.com:AikidoSec/safe-chain into feat/dynamic-install-dir 2026-04-13 12:22:03 -07:00
Reinier Criel
60732c5b6a Test 2026-04-13 12:21:31 -07:00
Reinier Criel
dec9e82ee9 Some more improvements 2026-04-13 11:32:51 -07:00
Reinier Criel
56a54b8683
Update packages/safe-chain/src/shell-integration/supported-shells/zsh.js
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-13 11:17:51 -07:00
Reinier Criel
32408c6583
Update packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-13 11:17:39 -07:00
Reinier Criel
f2bdd28ae6
Update packages/safe-chain/src/shell-integration/supported-shells/powershell.js
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-13 11:17:27 -07:00
Reinier Criel
5bbf3da576
Update packages/safe-chain/src/shell-integration/supported-shells/fish.js
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-13 11:17:15 -07:00
Reinier Criel
f07d0ea888
Update packages/safe-chain/src/shell-integration/supported-shells/bash.js
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-13 11:17:02 -07:00
Reinier Criel
72dc7dcf3a Fix spacing 2026-04-13 11:13:03 -07:00
Reinier Criel
031c9683b1 Some more cleanup 2026-04-13 11:10:16 -07:00
Reinier Criel
d064d46668 Cleanup 2026-04-13 11:01:45 -07:00
Reinier Criel
1cf8fd1241 Merge remote-tracking branch 'origin/main' into feat/dynamic-install-dir 2026-04-13 10:21:33 -07:00
bitterpanda
83f9f378f6
Merge pull request #412 from AikidoSec/feat/test-matrix 2026-04-13 16:48:52 +02:00
bitterpanda
50f23d27fd
Merge pull request #413 from AikidoSec/feat/endpoint-1-2-16 2026-04-13 09:52:44 +02:00
Reinier Criel
e3077ebd6f Update endpoint package download link to 1.2.16 2026-04-12 21:24:41 -07:00
Reinier Criel
9d5503aa54 Remove Node 16 from test matrix 2026-04-10 20:38:50 -07:00
Reinier Criel
2ea5362b07 Increase timeout for tests 2026-04-10 15:47:21 -07:00
Reinier Criel
df8be031cb Validate ENV VAR 2026-04-10 15:38:51 -07:00
Reinier Criel
98dcda78da Some more cleanup 2026-04-10 15:33:30 -07:00
Reinier Criel
ccd595fc22 Merge branch 'feat/dynamic-install-dir' of github.com:AikidoSec/safe-chain into feat/dynamic-install-dir 2026-04-10 15:27:16 -07:00
Reinier Criel
94f77e1330 Address more code quality issues 2026-04-10 15:25:50 -07:00
Reinier Criel
e5c79e5bd6
Update packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-10 15:21:05 -07:00
Reinier Criel
8cf41dc4a6
Update packages/safe-chain/src/shell-integration/supported-shells/bash.js
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-10 15:20:53 -07:00
Reinier Criel
d7400a0bc0
Update packages/safe-chain/src/shell-integration/supported-shells/zsh.js
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
2026-04-10 15:20:37 -07:00
Reinier Criel
eb9d0bba3e Code Quality 2026-04-10 15:16:33 -07:00
Reinier Criel
6628e1d4fd Some cleanup 2026-04-10 14:57:45 -07:00
Reinier Criel
32c95dbb9d Fix WIndows shell + unit tests 2026-04-10 14:27:55 -07:00
Reinier Criel
1aef941d1c Update README 2026-04-10 14:13:34 -07:00
Reinier Criel
b0f392522b Some cleanup 2026-04-10 14:08:59 -07:00
Reinier Criel
24af6f21eb Add regular setup support 2026-04-10 12:09:40 -07:00
Reinier Criel
1635bee387 Add support for setup-ci with custom install dir 2026-04-10 10:18:49 -07:00
Reinier Criel
422963b38a Do not hardcode path in setup-ci 2026-04-10 09:05:29 -07:00
Reinier Criel
a0fb8d6b3d Add env var support for home dir 2026-04-10 08:57:08 -07:00
Sander Declerck
698a12082d
Merge pull request #409 from AikidoSec/endpoint-v1-2-13
Update Aikido Endpoint version to 1.2.13
2026-04-09 13:23:48 +02:00
Sander Declerck
a6960d81e3
Update Aikido Endpoint version to 1.2.13 2026-04-09 13:11:29 +02:00
James McMeeking
178b8a4423
Merge branch 'main' into feature/add-rush-monorepo-support 2026-04-08 16:24:23 +01:00
Sander Declerck
738b1062b7
Merge pull request #404 from AikidoSec/immutable-releases-beta
Fix release pipeline for immutable build
2026-04-08 16:54:21 +02:00
Sander Declerck
b116bc7016
Add doc about release process 2026-04-08 14:09:26 +02:00
Sander Declerck
f1307c6d82
Fix release pipeline for immutable builds again 2026-04-08 13:16:14 +02:00
bitterpanda
2c568bb2a2
Merge pull request #401 from AikidoSec/remove-archiver-dependency 2026-04-07 17:26:54 +02:00
Sander Declerck
6db9f346e3
Undo accidental rename 2026-04-07 17:20:56 +02:00
Sander Declerck
070afb9364
Remove archiver dependency and safe-chain ultimate troubleshooting 2026-04-07 17:19:45 +02: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
willem-delbare
ca418de803
Merge pull request #393 from AikidoSec/npm-shrinkwrap-beta
Npm shrinkwrap CI fix
2026-04-06 15:32:14 +02:00
Sander Declerck
47ee9718d3
Remove check on npm release 2026-04-06 15:15:01 +02:00
Sander Declerck
a5541df5ec
Fix pre-release publishing 2026-04-06 15:08:23 +02:00
Sander Declerck
ae63d42ae9
Copy shrinkwrap before publishing 2026-04-06 15:03:11 +02:00
willem-delbare
7994c42f8c
Add npm-shrinkwrap.json file 2026-04-06 14:30:49 +02: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
bitterpanda
3f47ae890c
Merge pull request #389 from AikidoSec/feat/noisy-log 2026-04-04 10:28:11 +02:00
Reinier Criel
aeb3a47cab Change log level 2026-04-03 14:32:10 -07:00
bitterpanda
72f3ad48cd
Merge pull request #388 from AikidoSec/fix-releases
Fix releases to create draft
2026-04-03 16:47:01 +02:00
bitterpanda
458f7c3c42 Fix releases to create draft 2026-04-03 16:43:36 +02:00
bitterpanda
55f5624ddd
Merge pull request #387 from AikidoSec/endpoint-v1-2-12 2026-04-03 14:35:09 +02:00
Sander Declerck
4d87285fb7
Aikido endpoint 1.2.12 2026-04-03 14:23:31 +02:00
Sander Declerck
ef6a714910
Merge pull request #383 from AikidoSec/fix-version-windows
Fix version number on Windows
2026-04-03 10:53:59 +02:00
Sander Declerck
299480aa83
Merge branch 'main' into fix-version-windows 2026-04-03 10:13:09 +02:00
bitterpanda
da9e3d475e
Merge pull request #365 from 123Haynes/main
add a configuration option for custom malwaredb and newpackagelist urls.
2026-04-03 02:26:34 +02:00
123Haynes
edc708f8ff log which url was used to fetch the malware lists and why 2026-04-02 21:02:05 +00:00
bitterpanda
841dbf9a36
Merge pull request #376 from AikidoSec/feat/pypi-downgrade
Check meta data for pip packages and downgrade if too new
2026-04-02 13:04:50 -07:00
Reinier Criel
1a2805ba56 Adapt per review 2026-04-02 13:00:01 -07:00
Reinier Criel
0aabba668e Adapt per review 2026-04-02 08:56:20 -07:00
Sander Declerck
e12ae31795
Fix version number on Windows 2026-04-02 15:58:19 +02: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
Sander Declerck
308ccb3d2b
Merge pull request #380 from AikidoSec/endpoint-v1-2_11
Update Aikido Endpoint version to 1.2.11
2026-04-02 12:17:21 +02:00
Sander Declerck
2bf6ba2502
Update Aikido Endpoint version to 1.2.11 2026-04-02 09:46:28 +02:00
Reinier Criel
06ef0c3990 Adapt per review 2026-04-01 20:08:56 -07:00
Reinier Criel
c696386825 Some more cleanup 2026-04-01 15:38:42 -07:00
Reinier Criel
2b1247cf36 Code Quality 2026-04-01 15:23:25 -07:00
Reinier Criel
27e77d9b0b Fix regex 2026-04-01 15:19:39 -07:00
Reinier Criel
1a811edc95 More cleanup 2026-04-01 14:57:24 -07:00
Reinier Criel
e29c11546c Some cleanup 2026-04-01 14:43:00 -07:00
Reinier Criel
4564b7f607 Initial 2026-04-01 14:32:36 -07:00
123Haynes
f01d935bb1 remove trailing slashes and fix test failures 2026-04-01 07:08:30 +00:00
bitterpanda
2676170b61
Merge pull request #369 from AikidoSec/update-v1-2-9
Update to endpoint v1.2.9 in install script
2026-03-31 23:20:47 -07:00
bitterpanda
55024ca1c3 Update to endpoint v1.2.9 in install script 2026-03-31 23:19:28 -07:00
Sander Declerck
4f5d9f800e
Merge pull request #362 from AikidoSec/endpoint-v1-2-8
Update Aikido Endpoint version to 1.2.8
2026-03-31 17:15:23 +02:00
123Haynes
1abe5932ad add a configuration option for custom malwaredb and newpackagelist urls. 2026-03-31 11:52:26 +00:00
willem-delbare
5bc8b39f56
Merge pull request #363 from AikidoSec/pin-axios-version
Pin axios version in tests
2026-03-31 10:01:01 +02:00
Sander Declerck
136e66b1d0
Pin axios version in tests 2026-03-31 09:59:08 +02:00
Sander Declerck
8810544c7c
Update Aikido Endpoint version to 1.2.8 2026-03-31 08:08:33 +02:00
bitterpanda
5e63a83238
Merge pull request #359 from AikidoSec/feature/new-package-list-pypi
Add minimum package age check for pypi
2026-03-30 11:18:36 -07:00
Reinier Criel
6f1299a29d Merge remote-tracking branch 'origin/main' into feature/new-package-list-pypi 2026-03-30 07:58:24 -07:00
Reinier Criel
2ba6aaa46e Adapt per review 2026-03-30 07:58:14 -07:00
Sander Declerck
967e57ad46
Merge pull request #361 from AikidoSec/rename-ultimate-to-endpoint
Rename safe-chain ultimate to Aikido Endpoint
2026-03-30 16:40:40 +02:00
Sander Declerck
99e822d509
Rename safe-chain ultimate to Aikido Endpoint 2026-03-30 12:03:36 +02:00
Reinier Criel
d84270be8d Adapt per review 2026-03-28 16:51:33 -07:00
Reinier Criel
aa7bbbd4e9 Code Quality 2026-03-28 11:39:02 -07:00
Reinier Criel
fd6fb456b4 Add minimum package age check for pypi 2026-03-28 10:15:13 -07:00
bitterpanda
2c8a1b4972
Merge pull request #356 from AikidoSec/split-up-new-packages-database
Split up newPackagesDatabse into builder, warnigns, cache
2026-03-27 16:22:35 -07:00
BitterPanda
f434cd6aa2 Merge branch 'rmove-mentions-of-scraped-field' 2026-03-27 16:12:25 -07:00
BitterPanda
4b21ba2709 Fix ts error 2026-03-27 16:12:15 -07:00
BitterPanda
77659efe1f remove mentions of scraped_on field from types & test cases 2026-03-27 16:10:18 -07:00
BitterPanda
706e5040ae Merge remote-tracking branch 'origin/split-up-new-packages-database' into split-up-new-packages-database 2026-03-27 16:09:50 -07:00
bitterpanda
10c078a993 fix broken test case for newPackagesListCache 2026-03-27 16:09:04 -07:00
bitterpanda
faf0ba898c
Apply suggestions from code review
Co-authored-by: bitterpanda <bitterpanda@proton.me>
2026-03-27 15:54:30 -07:00
bitterpanda
5b1cd7e8da Split up newPackagesDatabse into builder, warnigns, cache 2026-03-27 15:52:07 -07:00
bitterpanda
f920fc61ac
Merge pull request #354 from AikidoSec/feature/minimum-package-age-from-list
Use new package feed to enforce minimum package age for direct npm downloads
2026-03-27 15:38:19 -07:00
Reinier Criel
3a01a92f03 Code Quality 2026-03-27 15:14:13 -07:00
Reinier Criel
8133f0c970 Some more cleanup 2026-03-27 14:38:41 -07:00
Reinier Criel
8a4f759a78 Some cleanup 2026-03-27 14:25:58 -07:00
Reinier Criel
2df8ce463c Adapt per review 2026-03-27 13:17:58 -07:00
Reinier Criel
8353f353ae Fix per review comment 2026-03-27 11:52:55 -07:00
Reinier Criel
a53fc736e9 Fix yarn URL issue 2026-03-27 11:45:26 -07:00
Reinier Criel
db31fa9f41 Fix unit test 2026-03-27 10:37:47 -07:00
Reinier Criel
edf6a1694f Some cleanups 2026-03-27 10:35:41 -07:00
Reinier Criel
e9db22eb50 Merge branch 'main' into feature/minimum-package-age-from-list 2026-03-26 14:37:07 -07:00
Sander Declerck
745a831d55
Merge pull request #353 from AikidoSec/manual-setup-teardown-instructions
Add manual setup and teardown instructions on failure
2026-03-26 15:55:45 +01:00
Sander Declerck
8717e25b79
Merge branch 'main' into manual-setup-teardown-instructions 2026-03-26 13:37:20 +01:00
Sander Declerck
50a931cf4d
Add manual setup and teardown instructions on failure 2026-03-26 13:36:20 +01:00
Reinier Criel
cc0f08dc03
Merge pull request #349 from AikidoSec/bug/ci-build-pre-release
Stop downloadAgent test from depending on live artifacts
2026-03-25 14:06:56 -07:00
Reinier Criel
9f3cd1b4da Don't rely on hardcoded URL 2026-03-25 13:16:42 -07:00
Reinier Criel
de33ceab41 Another fix 2026-03-25 13:06:14 -07:00
Reinier Criel
306c727832 Fix test 2026-03-25 13:03:48 -07:00
Reinier Criel
7433e97c4a Fix yml 2026-03-25 12:58:35 -07:00
bitterpanda
e6eadd9f92
Merge pull request #348 from AikidoSec/bitterpanda63-patch-3
Change runner to open-source-releaser in workflow
2026-03-25 11:06:37 -07:00
bitterpanda
33f50ba580
Change runner to open-source-releaser in workflow 2026-03-25 11:04:05 -07:00
Reinier Criel
16c51c2720 Add e2e test skeleton 2026-03-20 10:28:46 -07:00
Reinier Criel
ac09534070 Adapt per latest core 2026-03-20 09:11:02 -07:00
Reinier Criel
07e315a382 Adapt doc 2026-03-19 16:07:31 -07:00
Reinier Criel
2f4268f1af Add extra check 2026-03-19 15:58:42 -07:00
Reinier Criel
cddcec9ba5 Fetch new package list 2026-03-19 14:14:13 -07:00
138 changed files with 7155 additions and 2997 deletions

View file

@ -1,487 +0,0 @@
version: 2.1
# env:
# GITHUB_TOKEN — GitHub token with repo write access (used by gh CLI)
# NPM_PUBLISH_TOKEN — npm access token with publish rights
orbs:
windows: circleci/windows@5.0
executors:
linux-node20:
docker:
- image: cimg/node:20.18
resource_class: medium
linux-arm64-node20:
docker:
- image: cimg/node:20.18
resource_class: arm.medium
linux-machine:
machine:
image: ubuntu-2404:current
resource_class: medium
# Intel Mac — used for node20-macos-x64 target
macos-x64:
macos:
xcode: "16.0.0"
resource_class: macos.x86.medium.gen2
macos-arm64:
macos:
xcode: "16.0.0"
resource_class: macos.m2.medium.gen1
commands:
setup-node-20-macos:
steps:
- run:
name: Install Node.js 20
command: |
echo 'export NVM_DIR="$HOME/.nvm"' >> "$BASH_ENV"
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> "$BASH_ENV"
source "$BASH_ENV"
nvm install 20
nvm alias default 20
node --version
npm --version
setup-node-20-windows:
steps:
- run:
name: Install Node.js 20
command: |
nvm install 20.18.0
nvm use 20.18.0
node --version
npm --version
install-safe-chain:
steps:
- run:
name: Setup safe-chain
command: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
set-package-version:
steps:
- run:
name: Set version in safe-chain package
command: |
source version.env
if [ -n "${VERSION}" ]; then
npm --no-git-tag-version version "${VERSION}" --workspace=packages/safe-chain --ignore-scripts
fi
# ---------------------------------------------------------------------------
# Jobs
# ---------------------------------------------------------------------------
jobs:
set-version:
executor: linux-machine
steps:
- checkout
- run:
name: Extract version and check pre-release status
command: |
VERSION="${CIRCLE_TAG}"
echo "VERSION=${VERSION}" > version.env
IS_PRERELEASE=$(gh release view "${VERSION}" \
--json isPrerelease --jq '.isPrerelease' \
--repo AikidoSec/safe-chain)
echo "IS_PRERELEASE=${IS_PRERELEASE}" >> version.env
cat version.env
- persist_to_workspace:
root: .
paths:
- version.env
build-macos-x64:
executor: macos-x64
steps:
- checkout
- attach_workspace:
at: .
- setup-node-20-macos
- install-safe-chain
- run:
name: Install dependencies
command: npm ci --ignore-scripts
- set-package-version
- run:
name: Create binary
command: node build.js node20-macos-x64
- run:
name: Stage artifact
command: |
mkdir -p artifacts
cp dist/safe-chain artifacts/safe-chain-macos-x64
- persist_to_workspace:
root: .
paths:
- artifacts/safe-chain-macos-x64
build-macos-arm64:
executor: macos-arm64
steps:
- checkout
- attach_workspace:
at: .
- setup-node-20-macos
- install-safe-chain
- run:
name: Install dependencies
command: npm ci --ignore-scripts
- set-package-version
- run:
name: Create binary
command: node build.js node20-macos-arm64
- run:
name: Stage artifact
command: |
mkdir -p artifacts
cp dist/safe-chain artifacts/safe-chain-macos-arm64
- persist_to_workspace:
root: .
paths:
- artifacts/safe-chain-macos-arm64
build-linux-x64:
executor: linux-node20
steps:
- checkout
- attach_workspace:
at: .
- install-safe-chain
- run:
name: Install dependencies
command: npm ci --ignore-scripts
- set-package-version
- run:
name: Create binary
command: node build.js node20-linux-x64
- run:
name: Stage artifact
command: |
mkdir -p artifacts
cp dist/safe-chain artifacts/safe-chain-linux-x64
- persist_to_workspace:
root: .
paths:
- artifacts/safe-chain-linux-x64
build-linux-arm64:
executor: linux-arm64-node20
steps:
- checkout
- attach_workspace:
at: .
- install-safe-chain
- run:
name: Install dependencies
command: npm ci --ignore-scripts
- set-package-version
- run:
name: Create binary
command: node build.js node20-linux-arm64
- run:
name: Stage artifact
command: |
mkdir -p artifacts
cp dist/safe-chain artifacts/safe-chain-linux-arm64
- persist_to_workspace:
root: .
paths:
- artifacts/safe-chain-linux-arm64
build-linuxstatic-x64:
executor: linux-node20
steps:
- checkout
- attach_workspace:
at: .
- install-safe-chain
- run:
name: Install dependencies
command: npm ci --ignore-scripts
- set-package-version
- run:
name: Create binary
command: node build.js node20-linuxstatic-x64
- run:
name: Stage artifact
command: |
mkdir -p artifacts
cp dist/safe-chain artifacts/safe-chain-linuxstatic-x64
- persist_to_workspace:
root: .
paths:
- artifacts/safe-chain-linuxstatic-x64
build-linuxstatic-arm64:
executor: linux-arm64-node20
steps:
- checkout
- attach_workspace:
at: .
- install-safe-chain
- run:
name: Install dependencies
command: npm ci --ignore-scripts
- set-package-version
- run:
name: Create binary
command: node build.js node20-linuxstatic-arm64
- run:
name: Stage artifact
command: |
mkdir -p artifacts
cp dist/safe-chain artifacts/safe-chain-linuxstatic-arm64
- persist_to_workspace:
root: .
paths:
- artifacts/safe-chain-linuxstatic-arm64
build-win:
# CircleCI has no Windows ARM64 runner, so both Windows targets are built on x64
executor:
name: windows/server-2022
shell: bash.exe
steps:
- checkout
- attach_workspace:
at: .
- setup-node-20-windows
- install-safe-chain
- run:
name: Install dependencies
command: npm ci --ignore-scripts
- set-package-version
- run:
name: Create win-x64 binary
command: node build.js node20-win-x64
- run:
name: Stage win-x64 artifact
command: |
mkdir -p artifacts
cp dist/safe-chain.exe artifacts/safe-chain-win-x64.exe
- run:
name: Create win-arm64 binary
command: node build.js node20-win-arm64
- run:
name: Stage win-arm64 artifact
command: cp dist/safe-chain.exe artifacts/safe-chain-win-arm64.exe
- persist_to_workspace:
root: .
paths:
- artifacts/safe-chain-win-x64.exe
- artifacts/safe-chain-win-arm64.exe
publish-binaries:
machine:
image: ubuntu-2404:current
resource_class: medium
circleci_ip_ranges: true
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Prepare release artifacts
command: |
source version.env
mkdir -p release-artifacts
cp artifacts/safe-chain-macos-x64 release-artifacts/safe-chain-macos-x64
cp artifacts/safe-chain-macos-arm64 release-artifacts/safe-chain-macos-arm64
cp artifacts/safe-chain-linux-x64 release-artifacts/safe-chain-linux-x64
cp artifacts/safe-chain-linux-arm64 release-artifacts/safe-chain-linux-arm64
cp artifacts/safe-chain-linuxstatic-x64 release-artifacts/safe-chain-linuxstatic-x64
cp artifacts/safe-chain-linuxstatic-arm64 release-artifacts/safe-chain-linuxstatic-arm64
cp artifacts/safe-chain-win-x64.exe release-artifacts/safe-chain-win-x64.exe
cp artifacts/safe-chain-win-arm64.exe release-artifacts/safe-chain-win-arm64.exe
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
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
cp install-scripts/install-endpoint-windows.ps1 release-artifacts/install-endpoint-windows.ps1
cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh
cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1
- run:
name: Upload binaries to GitHub Release
command: |
source version.env
gh release upload "${VERSION}" \
release-artifacts/safe-chain-macos-x64 \
release-artifacts/safe-chain-macos-arm64 \
release-artifacts/safe-chain-linux-x64 \
release-artifacts/safe-chain-linux-arm64 \
release-artifacts/safe-chain-linuxstatic-x64 \
release-artifacts/safe-chain-linuxstatic-arm64 \
release-artifacts/safe-chain-win-x64.exe \
release-artifacts/safe-chain-win-arm64.exe \
release-artifacts/install-safe-chain.sh \
release-artifacts/install-safe-chain.ps1 \
release-artifacts/uninstall-safe-chain.sh \
release-artifacts/uninstall-safe-chain.ps1 \
release-artifacts/install-endpoint-mac.sh \
release-artifacts/install-endpoint-windows.ps1 \
release-artifacts/uninstall-endpoint-mac.sh \
release-artifacts/uninstall-endpoint-windows.ps1 \
--repo AikidoSec/safe-chain
publish-npm:
executor: linux-node20
steps:
- checkout
- attach_workspace:
at: .
- run:
name: Skip if pre-release
command: |
source version.env
if [ "${IS_PRERELEASE}" = "true" ]; then
echo "Pre-release tag detected — skipping npm publish"
circleci-agent step halt
fi
- install-safe-chain
- run:
name: Set the version in safe-chain package
command: |
source version.env
npm --no-git-tag-version version "${VERSION}" --workspace=packages/safe-chain
- run:
name: Install dependencies
command: npm ci
- run:
name: Run tests
command: npm run test
- run:
name: Copy documentation files to package
command: |
cp README.md packages/safe-chain/
cp LICENSE packages/safe-chain/
cp -r docs packages/safe-chain/
- run:
name: Configure npm authentication
command: echo "//registry.npmjs.org/:_authToken=${NPM_PUBLISH_TOKEN}" >> ~/.npmrc
- run:
name: Publish to npm
command: |
source version.env
echo "Publishing version ${VERSION} to NPM"
npm publish --workspace=packages/safe-chain --access public --provenance
# ---------------------------------------------------------------------------
# Workflow — triggered on every tag push (mirrors GitHub's on.push.tags: ["*"])
# ---------------------------------------------------------------------------
# IMPORTANT: In CircleCI, tag filters must be repeated on every job in the
# workflow, otherwise those jobs are skipped for tag-triggered pipelines.
workflows:
release:
jobs:
- set-version:
filters:
branches:
ignore: /.*/
tags:
only: /.*/
- build-macos-x64:
requires:
- set-version
filters:
branches:
ignore: /.*/
tags:
only: /.*/
- build-macos-arm64:
requires:
- set-version
filters:
branches:
ignore: /.*/
tags:
only: /.*/
- build-linux-x64:
requires:
- set-version
filters:
branches:
ignore: /.*/
tags:
only: /.*/
- build-linux-arm64:
requires:
- set-version
filters:
branches:
ignore: /.*/
tags:
only: /.*/
- build-linuxstatic-x64:
requires:
- set-version
filters:
branches:
ignore: /.*/
tags:
only: /.*/
- build-linuxstatic-arm64:
requires:
- set-version
filters:
branches:
ignore: /.*/
tags:
only: /.*/
- build-win:
requires:
- set-version
filters:
branches:
ignore: /.*/
tags:
only: /.*/
# publish-binaries and publish-npm both fan in from all build jobs and
# run in parallel, matching the original GitHub Actions structure.
- publish-binaries:
requires:
- build-macos-x64
- build-macos-arm64
- build-linux-x64
- build-linux-arm64
- build-linuxstatic-x64
- build-linuxstatic-arm64
- build-win
filters:
branches:
ignore: /.*/
tags:
only: /.*/
- publish-npm:
requires:
- build-macos-x64
- build-macos-arm64
- build-linux-x64
- build-linux-arm64
- build-linuxstatic-x64
- build-linuxstatic-arm64
- build-win
filters:
branches:
ignore: /.*/
tags:
only: /.*/

View file

@ -1,8 +1,11 @@
name: Create Release name: Create Release
# Workflow disabled — release pipeline moved to CircleCI (.circleci/config.yml)
on: on:
workflow_dispatch: push:
tags:
- "*"
release:
types: [published]
permissions: permissions:
id-token: write id-token: write
@ -11,30 +14,19 @@ permissions:
jobs: jobs:
set-version: set-version:
name: Set version number name: Set version number
runs-on: standard-runner-no-rights-public-ip if: github.event_name == 'push'
runs-on: open-source-releaser
outputs: outputs:
version: ${{ steps.get_version.outputs.tag }} version: ${{ steps.get_version.outputs.tag }}
is_prerelease: ${{ steps.check_prerelease.outputs.is_prerelease }}
steps: steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set version number - name: Set version number
id: get_version id: get_version
run: | run: |
version="${{ github.ref_name }}" version="${{ github.ref_name }}"
echo "tag=$version" >> $GITHUB_OUTPUT echo "tag=$version" >> $GITHUB_OUTPUT
- name: Check if pre-release
id: check_prerelease
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IS_PRERELEASE=$(gh release view ${{ steps.get_version.outputs.tag }} --json isPrerelease --jq '.isPrerelease')
echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
echo "Release ${{ steps.get_version.outputs.tag }} is pre-release: $IS_PRERELEASE"
create-binaries: create-binaries:
if: github.event_name == 'push'
needs: set-version needs: set-version
uses: ./.github/workflows/create-artifact.yml uses: ./.github/workflows/create-artifact.yml
with: with:
@ -42,9 +34,9 @@ jobs:
publish-binaries: publish-binaries:
name: Publish to GitHub release name: Publish to GitHub release
if: github.event_name == 'push'
needs: [set-version, create-binaries] needs: [set-version, create-binaries]
runs-on: standard-runner-no-rights-public-ip runs-on: open-source-releaser
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -68,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-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 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: env:
VERSION: ${{ needs.set-version.outputs.version }} VERSION: ${{ needs.set-version.outputs.version }}
run: | run: |
sed "s/\$(fetch_latest_version)/${VERSION}/" install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh SHA_MACOS_X64=$(sha256sum release-artifacts/safe-chain-macos-x64 | awk '{print $1}')
sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1 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.sh release-artifacts/uninstall-safe-chain.sh
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1 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 cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh
@ -81,11 +104,15 @@ jobs:
cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh cp install-scripts/uninstall-endpoint-mac.sh release-artifacts/uninstall-endpoint-mac.sh
cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1 cp install-scripts/uninstall-endpoint-windows.ps1 release-artifacts/uninstall-endpoint-windows.ps1
- name: Upload binaries to existing GitHub Release - name: Create draft release and upload assets
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.set-version.outputs.version }}
run: | run: |
gh release upload ${{ needs.set-version.outputs.version }} \ if ! gh release view "$VERSION" &>/dev/null; then
gh release create "$VERSION" --draft --title "$VERSION" --generate-notes
fi
gh release upload "$VERSION" --clobber \
release-artifacts/safe-chain-macos-x64 \ release-artifacts/safe-chain-macos-x64 \
release-artifacts/safe-chain-macos-arm64 \ release-artifacts/safe-chain-macos-arm64 \
release-artifacts/safe-chain-linux-x64 \ release-artifacts/safe-chain-linux-x64 \
@ -105,8 +132,7 @@ jobs:
publish-npm: publish-npm:
name: Publish to npm name: Publish to npm
needs: [set-version, create-binaries] if: github.event_name == 'release'
if: needs.set-version.outputs.is_prerelease != 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -118,14 +144,12 @@ jobs:
with: with:
node-version: "lts/*" node-version: "lts/*"
registry-url: "https://registry.npmjs.org/" registry-url: "https://registry.npmjs.org/"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
- name: Setup safe-chain - name: Setup safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: Set the version in safe-chain package - name: Set the version in safe-chain package
run: npm --no-git-tag-version version ${{ needs.set-version.outputs.version }} --workspace=packages/safe-chain run: npm --no-git-tag-version version ${{ github.event.release.tag_name }} --workspace=packages/safe-chain
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@ -138,8 +162,15 @@ jobs:
cp README.md packages/safe-chain/ cp README.md packages/safe-chain/
cp LICENSE packages/safe-chain/ cp LICENSE packages/safe-chain/
cp -r docs packages/safe-chain/ cp -r docs packages/safe-chain/
cp npm-shrinkwrap.json packages/safe-chain/
- name: Publish to npm - name: Publish to npm
run: | run: |
echo "Publishing version ${{ needs.set-version.outputs.version }} to NPM" VERSION="${{ github.event.release.tag_name }}"
echo "Publishing version $VERSION to NPM"
if [[ "$VERSION" == *"-"* ]]; then
PRERELEASE_TAG=$(echo "$VERSION" | sed 's/.*-\([^-]*\)$/\1/')
npm publish --workspace=packages/safe-chain --access public --provenance --tag "$PRERELEASE_TAG"
else
npm publish --workspace=packages/safe-chain --access public --provenance npm publish --workspace=packages/safe-chain --access public --provenance
fi

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

@ -80,6 +80,7 @@ jobs:
if: inputs.version != '' if: inputs.version != ''
env: env:
VERSION: ${{ inputs.version }} VERSION: ${{ inputs.version }}
shell: bash
run: npm --no-git-tag-version version $VERSION --workspace=packages/safe-chain --ignore-scripts run: npm --no-git-tag-version version $VERSION --workspace=packages/safe-chain --ignore-scripts
- name: Create binary - name: Create binary

View file

@ -77,7 +77,7 @@ jobs:
- node_version: "20" - node_version: "20"
npm_version: "9.0.0" npm_version: "9.0.0"
yarn_version: "latest" yarn_version: "latest"
pnpm_version: "latest" pnpm_version: "10.0.0"
# Version pinning scenario # Version pinning scenario
- node_version: "22" - node_version: "22"
npm_version: "10.2.0" npm_version: "10.2.0"
@ -87,17 +87,12 @@ jobs:
- node_version: "18" - node_version: "18"
npm_version: "latest" npm_version: "latest"
yarn_version: "latest" yarn_version: "latest"
pnpm_version: "latest" pnpm_version: "10.0.0"
# Future compatibility (becomes LTS October 2025) # Future compatibility (becomes LTS October 2025)
- node_version: "24" - node_version: "24"
npm_version: "latest" npm_version: "latest"
yarn_version: "latest" yarn_version: "latest"
pnpm_version: "latest" pnpm_version: "latest"
# EOL compatibility testing - Node 16 (EOL Sept 2023)
- node_version: "16"
npm_version: "8.0.0"
yarn_version: "1.22.0"
pnpm_version: "8.0.0"
steps: steps:
- name: Checkout code - name: Checkout code

130
README.md
View file

@ -10,6 +10,14 @@
- ✅ **Blocks packages newer than 48 hours** without breaking your build - ✅ **Blocks packages newer than 48 hours** without breaking your build
- ✅ **Tokenless, free, no build data shared** - ✅ **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: Aikido Safe Chain supports the following package managers:
- 📦 **npm** - 📦 **npm**
@ -17,13 +25,17 @@ Aikido Safe Chain supports the following package managers:
- 📦 **yarn** - 📦 **yarn**
- 📦 **pnpm** - 📦 **pnpm**
- 📦 **pnpx** - 📦 **pnpx**
- 📦 **rush**
- 📦 **rushx**
- 📦 **bun** - 📦 **bun**
- 📦 **bunx** - 📦 **bunx**
- 📦 **pip** - 📦 **pip**
- 📦 **pip3** - 📦 **pip3**
- 📦 **uv** - 📦 **uv**
- 📦 **poetry** - 📦 **poetry**
- 📦 **uvx**
- 📦 **pipx** - 📦 **pipx**
- 📦 **pdm**
# Usage # Usage
@ -66,7 +78,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
### Verify the installation ### Verify the installation
1. **❗Restart your terminal** to start using the Aikido Safe Chain. 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 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: 2. **Verify the installation** by running the verification command:
@ -97,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. - 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`, `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: You can check the installed version by running:
@ -109,17 +121,26 @@ safe-chain --version
### Malware Blocking ### 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, 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 (npm only) ### Minimum package age
For npm packages, Safe Chain temporarily suppresses packages published within the last 48 hours (by default) until they have been validated against malware. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below. Safe Chain applies minimum package age checks to supported ecosystems.
⚠️ This feature **only applies to npm-based package managers** (npm, npx, yarn, pnpm, pnpx, bun, bunx) and does not apply to Python package managers (uv, pip, pip3, poetry, pipx). Current enforcement differs by ecosystem:
- npm-based package managers:
- during normal package resolution, Safe Chain suppresses versions that are newer than the configured minimum age from the package metadata returned by the registry
- for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages
- Python package managers:
- during package resolution, Safe Chain suppresses too-young files and releases from PyPI metadata responses
- for direct package download requests that bypass that metadata flow, Safe Chain can block the request itself using a cached list of newly released packages
By default, the minimum package age is 48 hours. This provides an additional security layer during the critical period when newly published packages are most vulnerable to containing undetected threats. You can configure this threshold or bypass this protection entirely - see the [Minimum Package Age Configuration](#minimum-package-age) section below.
### Shell Integration ### 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, 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** - ✅ **Bash**
- ✅ **Zsh** - ✅ **Zsh**
@ -183,7 +204,17 @@ You can set the logging level through multiple sources (in order of priority):
## Minimum Package Age ## Minimum Package Age
You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed through npm-based package managers. You can configure how long packages must exist before Safe Chain allows their installation. By default, packages must be at least 48 hours old before they can be installed.
For npm-based package managers, this check currently has two enforcement modes:
- Safe Chain suppresses too-young versions from package metadata during normal dependency resolution.
- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
For Python package managers, this check currently has two enforcement modes:
- Safe Chain suppresses too-young files and releases from PyPI metadata during dependency resolution.
- Safe Chain blocks direct package download requests when they are matched against the cached newly released packages list.
### Configuration Options ### Configuration Options
@ -215,13 +246,16 @@ You can set the minimum package age through multiple sources (in order of priori
Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization: Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization:
```shell ```shell
export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*" export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*"
``` ```
```json ```json
{ {
"npm": { "npm": {
"minimumPackageAgeExclusions": ["@aikidosec/*"] "minimumPackageAgeExclusions": ["@aikidosec/*"]
},
"pip": {
"minimumPackageAgeExclusions": ["requests"]
} }
} }
``` ```
@ -259,6 +293,65 @@ 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.
### Configuration Options
You can set the malware list base URL through multiple sources (in order of priority):
1. **CLI Argument** (highest priority):
```shell
npm install express --safe-chain-malware-list-base-url=https://your-mirror.com
```
2. **Environment Variable**:
```shell
export SAFE_CHAIN_MALWARE_LIST_BASE_URL=https://your-mirror.com
npm install express
```
3. **Config File** (`~/.safe-chain/config.json`):
```json
{
"malwareListBaseUrl": "https://your-mirror.com"
}
```
The base URL should point to a server that mirrors the structure of `https://malware-list.aikido.dev/`, including the following paths:
- `/malware_predictions.json` (JavaScript ecosystem malware database)
- `/malware_pypi.json` (Python ecosystem malware database)
- `/releases/npm.json` (JavaScript new packages list)
- `/releases/pypi.json` (Python new packages list)
## Custom Install Directory
By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by passing an explicit install directory to the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools.
When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`.
### Unix/Linux/macOS
```shell
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --install-dir /usr/local/.safe-chain
```
### Windows
```powershell
iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -InstallDir 'C:\ProgramData\safe-chain'"
```
# Usage in CI/CD # Usage in CI/CD
You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation.
@ -349,6 +442,7 @@ pipeline {
environment { environment {
// Jenkins does not automatically persist PATH updates from setup-ci, // Jenkins does not automatically persist PATH updates from setup-ci,
// so add the shims + binary directory explicitly for all stages. // so add the shims + binary directory explicitly for all stages.
// If you installed into a custom directory, replace ~/.safe-chain with that path here.
PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}" PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}"
} }
@ -386,7 +480,7 @@ steps:
name: Install name: Install
script: script:
- curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - 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 - npm ci
``` ```
@ -404,7 +498,7 @@ To add safe-chain in GitLab pipelines, you need to install it in the image runni
# Install safe-chain # Install safe-chain
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
# Add safe-chain to PATH # Add safe-chain to PATH (update paths if you used a custom install dir)
ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}" ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}"
``` ```
@ -457,4 +551,16 @@ npm-ci:
# Troubleshooting # 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)

25
docs/Release.md Normal file
View file

@ -0,0 +1,25 @@
# Release Guide
## Steps
### 1. Create and push a version tag
```bash
git tag 1.0.0
git push origin 1.0.0
```
This triggers the build pipeline, which compiles binaries for all platforms and creates a draft GitHub release.
### 2. Wait for artifacts to build
Monitor the [Actions tab](https://github.com/AikidoSec/safe-chain/actions) until the `Create Release` workflow completes.
### 3. Publish the GitHub release
1. Go to the [Releases page](https://github.com/AikidoSec/safe-chain/releases)
2. Open the draft release created for your tag
3. Add release notes
4. Click **Publish release**
Publishing the release automatically triggers an npm publish. Pre-release versions (e.g. `1.0.0-beta`) are published to npm under a tag matching the pre-release identifier (e.g. `beta`). Stable versions are published to the `latest` tag.

View file

@ -2,7 +2,7 @@
## Overview ## Overview
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `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 ## Supported Shells
@ -28,7 +28,7 @@ This command:
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`) - Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
- Detects all supported shells on your system - 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`, `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 - 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. ❗ 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: 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 - 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-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 - Check that these commands are in your system's PATH
### Manual Verification ### Manual Verification
@ -121,7 +121,7 @@ npm() {
} }
``` ```
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `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: 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 ## Verification & Diagnostics
### Check Installation **Check Installation**
```bash ```bash
# Check version # Check version
safe-chain --version safe-chain --version
``` ```
### Verify Shell Integration **Verify Shell Integration**
Run the verification command for your package manager: Run the verification command for your package manager:
```bash ```bash
npm safe-chain-verify npm safe-chain-verify
pnpm 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!` Expected output: `OK: Safe-chain works!`
```
### Test Malware Blocking **Test Malware Blocking**
Verify that malware detection is working: Verify that malware detection is working:
**For JavaScript/Node.js:**
```bash
npm install safe-chain-test
``` ```
npm install safe-chain-test
**For Python:**
```bash
pip3 install safe-chain-pi-test
``` ```
These test packages are flagged as malware and should be blocked by Safe Chain. 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: 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. 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 ```bash
# For npm # For npm
npm cache clean --force npm cache clean --force
# For pnpm # For pnpm
pnpm store prune pnpm store prune
# For yarn (classic) # For yarn (classic)
yarn cache clean yarn cache clean
# For yarn (berry/v2+) # For yarn (berry/v2+)
yarn cache clean --all yarn cache clean --all
# For bun # For bun
bun pm cache rm 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 3) Re-test malware blocking:
# Remove node_modules if you want a completely fresh install
rm -rf node_modules
```
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 ### 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/`: Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`:
- Bash: `~/.bashrc` * Bash: `~/.bashrc`
- Zsh: `~/.zshrc` * Zsh: `~/.zshrc`
- Fish: `~/.config/fish/config.fish` * Fish: `~/.config/fish/config.fish`
- PowerShell: `$PROFILE` * PowerShell: `$PROFILE`
### "Command Not Found: safe-chain" ### "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. **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 ```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
``` ```
This allows: This allows:
- Local scripts (like safe-chain's) to run without signing
- Downloaded scripts to run only if signed by a trusted publisher
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 ### Shell Aliases Persist After Uninstallation
**Symptom:** safe-chain commands still active after running uninstall script **Symptom:** safe-chain commands still active after running uninstall script
**Steps:** **Steps**
1. Run `safe-chain teardown` (if binary still exists) 1. Run `safe-chain teardown` (if binary still exists)
2. Restart your terminal 2. Restart your terminal
3. If still present, manually edit shell config files: 3. If still present, manually edit shell config files:
- Bash: `~/.bashrc` * Bash: `~/.bashrc`
- Zsh: `~/.zshrc` * Zsh: `~/.zshrc`
- Fish: `~/.config/fish/config.fish` * Fish: `~/.config/fish/config.fish`
- PowerShell: `$PROFILE` * PowerShell: `$PROFILE`
4. Remove lines that source scripts from `~/.safe-chain/scripts/` 4. Remove lines that source scripts from `~/.safe-chain/scripts/`
5. Restart terminal again 5. Restart terminal again
@ -217,10 +206,10 @@ type pip
**Expected `which` output:** **Expected `which` output:**
- Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users/<username>/.safe-chain/bin/safe-chain` * 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 * 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 ### Check Shell Integration
@ -259,23 +248,23 @@ for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do
done 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. > **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 ```bash
npm uninstall -g @aikidosec/safe-chain npm uninstall -g @aikidosec/safe-chain
``` ```
### Remove Volta Installation #### Remove Volta Installation
```bash ```bash
volta uninstall @aikidosec/safe-chain volta uninstall @aikidosec/safe-chain
``` ```
### Remove nvm Installations (All Versions) #### Remove nvm Installations (All Versions)
```bash ```bash
# Automated approach # Automated approach
@ -288,34 +277,22 @@ nvm use <version>
npm uninstall -g @aikidosec/safe-chain npm uninstall -g @aikidosec/safe-chain
``` ```
### Clean Shell Configuration Files #### Clean Shell Configuration Files
Manually remove safe-chain entries from: Manually remove safe-chain entries from:
- Bash: `~/.bashrc` * Bash: `~/.bashrc`
- Zsh: `~/.zshrc` * Zsh: `~/.zshrc`
- Fish: `~/.config/fish/config.fish` * Fish: `~/.config/fish/config.fish`
- PowerShell: `$PROFILE` * PowerShell: `$PROFILE`
Look for and remove: Look for and remove:
- Lines sourcing from `~/.safe-chain/scripts/` * Lines sourcing from `~/.safe-chain/scripts/`
- Any safe-chain related function definitions * Any safe-chain related function definitions
### Remove Installation Directory #### Remove Installation Directory
```bash ```bash
rm -rf ~/.safe-chain 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)

14
install-scripts/install-endpoint-mac.sh Normal file → Executable file
View file

@ -1,14 +1,14 @@
#!/bin/sh #!/bin/sh
# Downloads and installs SafeChain Ultimate endpoint on macOS # Downloads and installs Aikido Endpoint Protection on macOS
# #
# Usage: curl -fsSL <url> | sudo sh -s -- --token <TOKEN> # Usage: curl -fsSL <url> | sudo sh -s -- --token <TOKEN>
set -e # Exit on error set -e # Exit on error
# Configuration # Configuration
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.5/SafeChainUltimate.pkg" INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.pkg"
DOWNLOAD_SHA256="abc2b0e6c6a4ca33cd893eeb16744f9f2da90013fb1abac301f5c00c2ad8bc30" DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe"
TOKEN_FILE="/tmp/aikido_endpoint_token.txt" TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
# Colors for output # Colors for output
@ -111,10 +111,10 @@ main() {
esac esac
# 2. Download and verify checksum # 2. Download and verify checksum
PKG_FILE=$(mktemp /tmp/SafeChainUltimate.XXXXXX.pkg) PKG_FILE=$(mktemp /tmp/AikidoEndpoint.XXXXXX.pkg)
trap cleanup EXIT trap cleanup EXIT
info "Downloading SafeChain Ultimate..." info "Downloading Aikido Endpoint Protection..."
download "$INSTALL_URL" "$PKG_FILE" download "$INSTALL_URL" "$PKG_FILE"
info "Verifying checksum..." info "Verifying checksum..."
@ -124,10 +124,10 @@ main() {
printf "%s" "$TOKEN" > "$TOKEN_FILE" printf "%s" "$TOKEN" > "$TOKEN_FILE"
# 4. Install the package # 4. Install the package
info "Installing SafeChain Ultimate..." info "Installing Aikido Endpoint Protection..."
installer -pkg "$PKG_FILE" -target / installer -pkg "$PKG_FILE" -target /
info "SafeChain Ultimate installed successfully!" info "Aikido Endpoint Protection installed successfully!"
} }
main "$@" main "$@"

View file

@ -1,4 +1,4 @@
# Downloads and installs SafeChain Ultimate endpoint on Windows # Downloads and installs Aikido Endpoint Protection on Windows
# #
# Usage: iex "& { $(iwr '<url>' -UseBasicParsing) } -token <TOKEN>" # Usage: iex "& { $(iwr '<url>' -UseBasicParsing) } -token <TOKEN>"
@ -7,8 +7,8 @@ param(
) )
# Configuration # Configuration
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.5/SafeChainUltimate.msi" $InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.msi"
$DownloadSha256 = "c4d1be7bb2128473b8e955244dc186b5d3f091f668b43cdd3d810cff9d38193c" $DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be"
# Ensure TLS 1.2 is enabled for downloads # Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
@ -53,9 +53,9 @@ function Install-Endpoint {
} }
# 2. Download the .msi # 2. Download the .msi
$msiFile = Join-Path $env:TEMP "SafeChainUltimate-$([System.Guid]::NewGuid().ToString('N')).msi" $msiFile = Join-Path $env:TEMP "AikidoEndpoint-$([System.Guid]::NewGuid().ToString('N')).msi"
Write-Info "Downloading SafeChain Ultimate..." Write-Info "Downloading Aikido Endpoint Protection..."
try { try {
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $InstallUrl -OutFile $msiFile -UseBasicParsing Invoke-WebRequest -Uri $InstallUrl -OutFile $msiFile -UseBasicParsing
@ -75,13 +75,13 @@ function Install-Endpoint {
Write-Info "Checksum verified successfully." Write-Info "Checksum verified successfully."
# 3. Install the package with token passed as MSI property # 3. Install the package with token passed as MSI property
Write-Info "Installing SafeChain Ultimate..." Write-Info "Installing Aikido Endpoint Protection..."
$process = Start-Process -FilePath "msiexec" -ArgumentList "/i", "`"$msiFile`"", "/qn", "/norestart", "AIKIDO_TOKEN=$token" -Wait -PassThru $process = Start-Process -FilePath "msiexec" -ArgumentList "/i", "`"$msiFile`"", "/qn", "/norestart", "AIKIDO_TOKEN=$token" -Wait -PassThru
if ($process.ExitCode -ne 0) { if ($process.ExitCode -ne 0) {
Write-Error-Custom "MSI installer failed (exit code: $($process.ExitCode))." Write-Error-Custom "MSI installer failed (exit code: $($process.ExitCode))."
} }
Write-Info "SafeChain Ultimate installed successfully!" Write-Info "Aikido Endpoint Protection installed successfully!"
} }
finally { finally {
# Cleanup # Cleanup

View file

@ -4,13 +4,68 @@
param( param(
[switch]$ci, [switch]$ci,
[switch]$includepython [switch]$includepython,
[string]$InstallDir
) )
# Validates and normalizes the requested install directory.
# Rejects non-absolute, root, PATH-like, and traversal-containing paths.
function Test-InstallDir {
param([string]$Dir)
if ([string]::IsNullOrWhiteSpace($Dir)) {
return @{ Ok = $true; Normalized = $null }
}
if (-not [System.IO.Path]::IsPathRooted($Dir)) {
return @{ Ok = $false; Reason = "-InstallDir must be an absolute path, got: $Dir" }
}
if ($Dir.Contains([System.IO.Path]::PathSeparator)) {
return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" }
}
$inputSegments = $Dir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries)
if ($inputSegments -contains "..") {
return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" }
}
$normalized = [System.IO.Path]::GetFullPath($Dir)
$root = [System.IO.Path]::GetPathRoot($normalized)
if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) {
return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" }
}
return @{ Ok = $true; Normalized = $normalized }
}
$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set
$InstallDir = Join-Path $env:USERPROFILE ".safe-chain\bin" $SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $HOME ".safe-chain" }
$installDirValidation = Test-InstallDir -Dir $SafeChainBase
if (-not $installDirValidation.Ok) {
Write-Host "[ERROR] $($installDirValidation.Reason)" -ForegroundColor Red
exit 1
}
$SafeChainBase = $installDirValidation.Normalized
$InstallDir = Join-Path $SafeChainBase "bin"
$RepoUrl = "https://github.com/AikidoSec/safe-chain" $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 # Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
@ -98,6 +153,91 @@ function Get-Architecture {
} }
} }
# Emits the deprecation warning for SAFE_CHAIN_VERSION and prints the version-pinned install command.
# Returns immediately when no version was provided through the environment.
function Write-VersionDeprecationWarning {
if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) {
return
}
Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated."
Write-Warn ""
Write-Warn "Please use direct download URLs for version pinning instead:"
Write-Warn ""
if ($ci) {
Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`""
} else {
Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)"
}
Write-Warn ""
}
# Builds the Windows release binary filename for the detected architecture.
# Centralizes binary name generation for the download step.
function Get-BinaryName {
param([string]$Architecture)
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 {
param(
[string]$BinaryPath,
[string]$InstallDirectory
)
$setupCmd = if ($ci) { "setup-ci" } else { "setup" }
Write-Info "Running safe-chain $setupCmd..."
try {
$env:Path = "$env:Path;$InstallDirectory"
& $BinaryPath $setupCmd
if ($LASTEXITCODE -ne 0) {
Write-Warn "safe-chain was installed but setup encountered issues."
Write-Warn "You can run 'safe-chain $setupCmd' manually later."
}
}
catch {
Write-Warn "safe-chain was installed but setup encountered issues: $_"
Write-Warn "You can run 'safe-chain $setupCmd' manually later."
}
}
# Check and uninstall npm global package if present # Check and uninstall npm global package if present
function Remove-NpmInstallation { function Remove-NpmInstallation {
# Check if npm is available # Check if npm is available
@ -149,19 +289,7 @@ function Remove-VoltaInstallation {
# Main installation # Main installation
function Install-SafeChain { function Install-SafeChain {
# Show deprecation warning if SAFE_CHAIN_VERSION is set Write-VersionDeprecationWarning
if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) {
Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated."
Write-Warn ""
Write-Warn "Please use direct download URLs for version pinning instead:"
Write-Warn ""
if ($ci) {
Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`""
} else {
Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)"
}
Write-Warn ""
}
# Fetch latest version if VERSION is not set # Fetch latest version if VERSION is not set
if ([string]::IsNullOrWhiteSpace($Version)) { if ([string]::IsNullOrWhiteSpace($Version)) {
@ -192,7 +320,7 @@ function Install-SafeChain {
# Detect platform # Detect platform
$arch = Get-Architecture $arch = Get-Architecture
$binaryName = "safe-chain-win-$arch.exe" $binaryName = Get-BinaryName -Architecture $arch
Write-Info "Detected architecture: $arch" Write-Info "Detected architecture: $arch"
@ -223,6 +351,9 @@ function Install-SafeChain {
Write-Error-Custom "Failed to download from $downloadUrl : $_" 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 # Rename to final location
$finalFile = Join-Path $InstallDir "safe-chain.exe" $finalFile = Join-Path $InstallDir "safe-chain.exe"
try { try {
@ -238,31 +369,7 @@ function Install-SafeChain {
Write-Info "Binary installed to: $finalFile" Write-Info "Binary installed to: $finalFile"
# Build setup command based on parameters Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir
$setupCmd = if ($ci) { "setup-ci" } else { "setup" }
$setupArgs = @()
# Execute safe-chain setup
Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..."
try {
$env:Path = "$env:Path;$InstallDir"
if ($setupArgs) {
& $finalFile $setupCmd $setupArgs
}
else {
& $finalFile $setupCmd
}
if ($LASTEXITCODE -ne 0) {
Write-Warn "safe-chain was installed but setup encountered issues."
Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later."
}
}
catch {
Write-Warn "safe-chain was installed but setup encountered issues: $_"
Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later."
}
} }
# Run installation # Run installation

View file

@ -6,11 +6,67 @@
set -e # Exit on error set -e # Exit on error
# Validates a user-provided install dir and exits on unsafe values.
# Rejects relative paths, root paths, PATH separators, and traversal segments.
validate_install_dir() {
dir="$1"
if [ -z "$dir" ]; then
return 0
fi
case "$dir" in
/*) ;;
*)
printf '[ERROR] --install-dir must be an absolute path, got: %s\n' "$dir" >&2
exit 1
;;
esac
case "$dir" in
*:*)
printf '[ERROR] --install-dir must not contain the PATH separator (:)\n' >&2
exit 1
;;
esac
if [ "$dir" = "/" ]; then
printf '[ERROR] --install-dir cannot be a root or drive-root directory\n' >&2
exit 1
fi
old_ifs=$IFS
IFS='/'
set -- $dir
IFS=$old_ifs
for segment in "$@"; do
if [ "$segment" = ".." ]; then
printf '[ERROR] --install-dir must not contain path traversal segments\n' >&2
exit 1
fi
done
}
# Configuration # Configuration
VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set
INSTALL_DIR="${HOME}/.safe-chain/bin" SAFE_CHAIN_BASE="${HOME}/.safe-chain"
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
REPO_URL="https://github.com/AikidoSec/safe-chain" 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 # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@ -112,6 +168,57 @@ fetch_latest_version() {
echo "$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 file
download() { download() {
url="$1" url="$1"
@ -126,6 +233,75 @@ download() {
fi fi
} }
# Prints the deprecation warning for SAFE_CHAIN_VERSION and the replacement install command.
# Returns immediately when no version was pinned through the environment.
warn_deprecated_version_env() {
if [ -z "$SAFE_CHAIN_VERSION" ]; then
return
fi
warn "SAFE_CHAIN_VERSION environment variable is deprecated."
warn ""
warn "Please use direct download URLs for version pinning instead:"
warn ""
if [ "$USE_CI_SETUP" = "true" ]; then
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci"
else
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh"
fi
warn ""
}
# Ensures VERSION is populated before installation continues.
# Fetches the latest release only when no explicit version was provided.
ensure_version() {
if [ -n "$VERSION" ]; then
return
fi
info "Fetching latest release version..."
VERSION=$(fetch_latest_version)
}
# Constructs platform-specific binary filename to match GitHub release asset naming convention.
get_binary_name() {
os="$1"
arch="$2"
if [ "$os" = "win" ]; then
printf 'safe-chain-%s-%s.exe\n' "$os" "$arch"
else
printf 'safe-chain-%s-%s\n' "$os" "$arch"
fi
}
# Returns the final installation path for the downloaded safe-chain binary.
# Uses INSTALL_DIR and the platform-specific executable name.
get_final_binary_path() {
os="$1"
if [ "$os" = "win" ]; then
printf '%s/safe-chain.exe\n' "$INSTALL_DIR"
else
printf '%s/safe-chain\n' "$INSTALL_DIR"
fi
}
run_setup_command() {
final_file="$1"
setup_cmd="setup"
if [ "$USE_CI_SETUP" = "true" ]; then
setup_cmd="setup-ci"
fi
info "Running safe-chain $setup_cmd..."
if ! "$final_file" "$setup_cmd"; then
warn "safe-chain was installed but setup encountered issues."
warn "You can run 'safe-chain $setup_cmd' manually later."
fi
}
# Check and uninstall npm global package if present # Check and uninstall npm global package if present
remove_npm_installation() { remove_npm_installation() {
if ! command_exists npm; then if ! command_exists npm; then
@ -229,19 +405,39 @@ remove_nvm_installation() {
# Parse command-line arguments # Parse command-line arguments
parse_arguments() { parse_arguments() {
for arg in "$@"; do while [ $# -gt 0 ]; do
case "$arg" in case "$1" in
--ci) --ci)
USE_CI_SETUP=true USE_CI_SETUP=true
;; ;;
--install-dir)
shift
if [ $# -eq 0 ]; then
error "Missing value for --install-dir"
fi
if [ -z "$1" ]; then
error "--install-dir must not be empty"
fi
SAFE_CHAIN_BASE="$1"
;;
--install-dir=*)
SAFE_CHAIN_BASE="${1#--install-dir=}"
if [ -z "$SAFE_CHAIN_BASE" ]; then
error "--install-dir must not be empty"
fi
;;
--include-python) --include-python)
warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." warn "--include-python is deprecated and ignored. Python ecosystem is now included by default."
;; ;;
*) *)
error "Unknown argument: $arg" error "Unknown argument: $1"
;; ;;
esac esac
shift
done done
validate_install_dir "${SAFE_CHAIN_BASE}"
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
} }
# Main installation # Main installation
@ -252,25 +448,9 @@ main() {
# Parse command-line arguments # Parse command-line arguments
parse_arguments "$@" parse_arguments "$@"
# Show deprecation warning if SAFE_CHAIN_VERSION is set warn_deprecated_version_env
if [ -n "$SAFE_CHAIN_VERSION" ]; then
warn "SAFE_CHAIN_VERSION environment variable is deprecated."
warn ""
warn "Please use direct download URLs for version pinning instead:"
warn ""
if [ "$USE_CI_SETUP" = "true" ]; then
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci"
else
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh"
fi
warn ""
fi
# Fetch latest version if VERSION is not set ensure_version
if [ -z "$VERSION" ]; then
info "Fetching latest release version..."
VERSION=$(fetch_latest_version)
fi
# Check if the requested version is already installed # Check if the requested version is already installed
if is_version_installed "$VERSION"; then if is_version_installed "$VERSION"; then
@ -294,11 +474,7 @@ main() {
# Detect platform # Detect platform
OS=$(detect_os) OS=$(detect_os)
ARCH=$(detect_arch) ARCH=$(detect_arch)
if [ "$OS" = "win" ]; then BINARY_NAME=$(get_binary_name "$OS" "$ARCH")
BINARY_NAME="safe-chain-${OS}-${ARCH}.exe"
else
BINARY_NAME="safe-chain-${OS}-${ARCH}"
fi
info "Detected platform: ${OS}-${ARCH}" info "Detected platform: ${OS}-${ARCH}"
@ -315,12 +491,11 @@ main() {
info "Downloading from: $DOWNLOAD_URL" info "Downloading from: $DOWNLOAD_URL"
download "$DOWNLOAD_URL" "$TEMP_FILE" download "$DOWNLOAD_URL" "$TEMP_FILE"
EXPECTED_SHA256=$(get_expected_sha256 "$OS" "$ARCH")
verify_checksum "$TEMP_FILE" "$EXPECTED_SHA256"
# Rename and make executable # Rename and make executable
if [ "$OS" = "win" ]; then FINAL_FILE=$(get_final_binary_path "$OS")
FINAL_FILE="${INSTALL_DIR}/safe-chain.exe"
else
FINAL_FILE="${INSTALL_DIR}/safe-chain"
fi
mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE"
if [ "$OS" != "win" ]; then if [ "$OS" != "win" ]; then
chmod +x "$FINAL_FILE" || error "Failed to make binary executable" chmod +x "$FINAL_FILE" || error "Failed to make binary executable"
@ -328,20 +503,7 @@ main() {
info "Binary installed to: $FINAL_FILE" info "Binary installed to: $FINAL_FILE"
# Build setup command based on arguments run_setup_command "$FINAL_FILE"
SETUP_CMD="setup"
SETUP_ARGS=""
if [ "$USE_CI_SETUP" = "true" ]; then
SETUP_CMD="setup-ci"
fi
# Execute safe-chain setup
info "Running safe-chain $SETUP_CMD $SETUP_ARGS..."
if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then
warn "safe-chain was installed but setup encountered issues."
warn "You can run 'safe-chain $SETUP_CMD $SETUP_ARGS' manually later."
fi
} }
main "$@" main "$@"

10
install-scripts/uninstall-endpoint-mac.sh Normal file → Executable file
View file

@ -1,13 +1,13 @@
#!/bin/sh #!/bin/sh
# Uninstalls SafeChain Ultimate endpoint on macOS # Uninstalls Aikido Endpoint Protection on macOS
# #
# Usage: curl -fsSL <url> | sudo sh # Usage: curl -fsSL <url> | sudo sh
set -e # Exit on error set -e # Exit on error
# Configuration # Configuration
UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall" UNINSTALL_SCRIPT="/Applications/Aikido Endpoint Protection.app/Contents/Resources/scripts/uninstall"
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
@ -38,13 +38,13 @@ main() {
# Check if the uninstall script exists # Check if the uninstall script exists
if [ ! -f "$UNINSTALL_SCRIPT" ]; then if [ ! -f "$UNINSTALL_SCRIPT" ]; then
error "SafeChain Ultimate does not appear to be installed (uninstall script not found)." error "Aikido Endpoint Protection does not appear to be installed (uninstall script not found)."
fi fi
info "Uninstalling SafeChain Ultimate..." info "Uninstalling Aikido Endpoint Protection..."
"$UNINSTALL_SCRIPT" "$UNINSTALL_SCRIPT"
info "SafeChain Ultimate uninstalled successfully!" info "Aikido Endpoint Protection uninstalled successfully!"
} }
main "$@" main "$@"

View file

@ -1,9 +1,9 @@
# Uninstalls SafeChain Ultimate endpoint on Windows # Uninstalls Aikido Endpoint Protection endpoint on Windows
# #
# Usage: iex (iwr '<url>' -UseBasicParsing) # Usage: iex (iwr '<url>' -UseBasicParsing)
# Configuration # Configuration
$AppName = "SafeChain Ultimate" $AppName = "Aikido Endpoint Protection"
# Helper functions # Helper functions
function Write-Info { function Write-Info {
@ -32,22 +32,22 @@ function Uninstall-Endpoint {
} }
# Find the installed product # Find the installed product
Write-Info "Looking for SafeChain Ultimate installation..." Write-Info "Looking for Aikido Endpoint Protection installation..."
$app = Get-WmiObject -Class Win32_Product -Filter "Name='$AppName'" $app = Get-WmiObject -Class Win32_Product -Filter "Name='$AppName'"
if (-not $app) { if (-not $app) {
Write-Error-Custom "SafeChain Ultimate does not appear to be installed." Write-Error-Custom "Aikido Endpoint Protection does not appear to be installed."
} }
$productCode = $app.IdentifyingNumber $productCode = $app.IdentifyingNumber
Write-Info "Uninstalling SafeChain Ultimate..." Write-Info "Uninstalling Aikido Endpoint Protection..."
$process = Start-Process -FilePath "msiexec" -ArgumentList "/x", $productCode, "/qn", "/norestart" -Wait -PassThru $process = Start-Process -FilePath "msiexec" -ArgumentList "/x", $productCode, "/qn", "/norestart" -Wait -PassThru
if ($process.ExitCode -ne 0) { if ($process.ExitCode -ne 0) {
Write-Error-Custom "Uninstall failed (exit code: $($process.ExitCode))." Write-Error-Custom "Uninstall failed (exit code: $($process.ExitCode))."
} }
Write-Info "SafeChain Ultimate uninstalled successfully!" Write-Info "Aikido Endpoint Protection uninstalled successfully!"
} }
# Run uninstallation # Run uninstallation

View file

@ -4,8 +4,6 @@
# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform)
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
$DotSafeChain = Join-Path $HomeDir ".safe-chain"
$InstallDir = Join-Path $DotSafeChain "bin"
# Helper functions # Helper functions
function Write-Info { function Write-Info {
@ -24,6 +22,146 @@ function Write-Error-Custom {
exit 1 exit 1
} }
# Derives the safe-chain base install directory from a resolved binary path.
# Rejects wrapper scripts and paths that do not match the packaged bin layout.
function Get-InstallDirFromBinaryPath {
param([string]$BinaryPath)
if ([string]::IsNullOrWhiteSpace($BinaryPath)) {
return $null
}
try {
$resolvedPath = (Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop).Path
}
catch {
$resolvedPath = [System.IO.Path]::GetFullPath($BinaryPath)
}
$fileName = [System.IO.Path]::GetFileName($resolvedPath)
if (($fileName -ne "safe-chain") -and ($fileName -ne "safe-chain.exe")) {
return $null
}
if ($resolvedPath -match '\.(js|cjs|mjs|cmd|ps1)$') {
return $null
}
$binDir = Split-Path -Parent $resolvedPath
if ((Split-Path -Leaf $binDir) -ne "bin") {
return $null
}
return (Split-Path -Parent $binDir)
}
# Returns the first safe-chain command found on PATH, if any.
# Used as the starting point for install-dir discovery.
function Get-SafeChainCommand {
return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1
}
# Returns the safe-chain command path only when it points to a valid packaged binary install.
# Prevents teardown from invoking arbitrary wrappers or scripts from PATH.
function Get-ValidatedSafeChainCommandPath {
$command = Get-SafeChainCommand
if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) {
return $null
}
$installDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path
if (-not $installDir) {
return $null
}
return $command.Path
}
# Invokes the validated safe-chain binary with get-install-dir and returns the reported base directory.
# Safely returns $null when the command is unavailable or the lookup fails.
function Get-ReportedInstallDir {
$safeChainPath = Get-ValidatedSafeChainCommandPath
if (-not $safeChainPath) {
return $null
}
try {
$reportedInstallDir = & $safeChainPath get-install-dir 2>$null | Select-Object -First 1
if ($reportedInstallDir) {
$reportedInstallDir = $reportedInstallDir.Trim()
}
if ($reportedInstallDir) {
return $reportedInstallDir
}
}
catch {
return $null
}
return $null
}
# Determines the safe-chain base install directory for uninstall.
# Prefers the binary-reported location, then derives it from PATH, then falls back to the default home-dir layout.
function Get-SafeChainInstallDir {
$reportedInstallDir = Get-ReportedInstallDir
if ($reportedInstallDir) {
return $reportedInstallDir
}
$command = Get-SafeChainCommand
if ($command -and $command.Path) {
$discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path
if ($discoveredInstallDir) {
return $discoveredInstallDir
}
}
return (Join-Path $HomeDir ".safe-chain")
}
# Finds the installed safe-chain binary inside the resolved install directory.
# Falls back to a validated safe-chain command when the expected file is missing.
function Find-SafeChainBinary {
param([string]$DotSafeChain)
$safeChainExe = Join-Path $DotSafeChain "bin/safe-chain.exe"
$safeChainBin = Join-Path $DotSafeChain "bin/safe-chain"
if (Test-Path $safeChainExe) {
return $safeChainExe
}
if (Test-Path $safeChainBin) {
return $safeChainBin
}
return Get-ValidatedSafeChainCommandPath
}
# Runs safe-chain teardown before removing the installation directory.
# Converts teardown failures into warnings so uninstall can still complete.
function Invoke-SafeChainTeardown {
param([string]$SafeChainPath)
if (-not $SafeChainPath) {
Write-Warn "safe-chain command not found. Proceeding with uninstallation."
return
}
Write-Info "Running safe-chain teardown..."
try {
& $SafeChainPath teardown
if ($LASTEXITCODE -ne 0) {
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
}
}
catch {
Write-Warn "safe-chain teardown encountered issues: $_"
Write-Warn "Continuing with uninstallation..."
}
}
# Check and uninstall npm global package if present # Check and uninstall npm global package if present
function Remove-NpmInstallation { function Remove-NpmInstallation {
# Check if npm is available # Check if npm is available
@ -76,49 +214,9 @@ function Remove-VoltaInstallation {
# Main uninstallation # Main uninstallation
function Uninstall-SafeChain { function Uninstall-SafeChain {
Write-Info "Uninstalling safe-chain..." Write-Info "Uninstalling safe-chain..."
$DotSafeChain = Get-SafeChainInstallDir
# Run teardown if safe-chain is available $safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain
# Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms Invoke-SafeChainTeardown -SafeChainPath $safeChainPath
$safeChainExe = Join-Path $InstallDir "safe-chain.exe"
$safeChainBin = Join-Path $InstallDir "safe-chain"
$safeChainPath = $null
if (Test-Path $safeChainExe) {
$safeChainPath = $safeChainExe
}
elseif (Test-Path $safeChainBin) {
$safeChainPath = $safeChainBin
}
if ($safeChainPath) {
Write-Info "Running safe-chain teardown..."
try {
& $safeChainPath teardown
if ($LASTEXITCODE -ne 0) {
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
}
}
catch {
Write-Warn "safe-chain teardown encountered issues: $_"
Write-Warn "Continuing with uninstallation..."
}
}
elseif (Get-Command safe-chain -ErrorAction SilentlyContinue) {
Write-Info "Running safe-chain teardown..."
try {
safe-chain teardown
if ($LASTEXITCODE -ne 0) {
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
}
}
catch {
Write-Warn "safe-chain teardown encountered issues: $_"
Write-Warn "Continuing with uninstallation..."
}
}
else {
Write-Warn "safe-chain command not found. Proceeding with uninstallation."
}
# Remove npm and Volta installations # Remove npm and Volta installations
Remove-NpmInstallation Remove-NpmInstallation

View file

@ -7,7 +7,6 @@
set -e # Exit on error set -e # Exit on error
# Configuration # Configuration
DOT_SAFE_CHAIN="${HOME}/.safe-chain"
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
@ -34,6 +33,159 @@ command_exists() {
command -v "$1" >/dev/null 2>&1 command -v "$1" >/dev/null 2>&1
} }
# Resolves a path to its canonical filesystem location when possible.
# Follows symlinks so binary validation can inspect the real installed path.
resolve_path() {
target="$1"
while [ -L "$target" ]; do
link_target=$(readlink "$target" 2>/dev/null || echo "")
if [ -z "$link_target" ]; then
break
fi
case "$link_target" in
/*) target="$link_target" ;;
*)
target="$(dirname "$target")/$link_target"
;;
esac
done
target_dir=$(dirname "$target")
target_name=$(basename "$target")
if cd "$target_dir" 2>/dev/null; then
printf '%s/%s\n' "$(pwd -P)" "$target_name"
else
printf '%s\n' "$target"
fi
}
# Derives the safe-chain base install directory from a packaged binary path.
# Rejects wrapper scripts and paths that do not match the expected bin layout.
derive_install_dir_from_binary() {
binary_path="$1"
if [ -z "$binary_path" ]; then
return 1
fi
resolved_path=$(resolve_path "$binary_path")
binary_name=$(basename "$resolved_path")
case "$binary_name" in
safe-chain|safe-chain.exe) ;;
*) return 1 ;;
esac
case "$resolved_path" in
*.js|*.cjs|*.mjs|*.cmd|*.ps1) return 1 ;;
esac
binary_dir=$(dirname "$resolved_path")
if [ "$(basename "$binary_dir")" != "bin" ]; then
return 1
fi
dirname "$binary_dir"
}
# Determines the installed safe-chain base directory for uninstall.
# Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain.
get_install_dir() {
reported_install_dir=$(get_reported_install_dir || true)
if [ -n "$reported_install_dir" ]; then
printf '%s\n' "$reported_install_dir"
return 0
fi
command_path=$(get_safe_chain_command_path || true)
install_dir=$(derive_install_dir_from_binary "$command_path" || true)
if [ -n "$install_dir" ]; then
printf '%s\n' "$install_dir"
return 0
fi
printf '%s\n' "${HOME}/.safe-chain"
}
# Returns the current safe-chain command path from PATH.
# Fails when safe-chain is not currently resolvable.
get_safe_chain_command_path() {
if ! command_exists safe-chain; then
return 1
fi
command -v safe-chain
}
# Returns the safe-chain command path only when it resolves to a valid packaged binary install.
# Prevents the uninstaller from invoking arbitrary PATH entries.
get_validated_safe_chain_command_path() {
command_path=$(get_safe_chain_command_path || true)
if [ -z "$command_path" ]; then
return 1
fi
install_dir=$(derive_install_dir_from_binary "$command_path" || true)
if [ -z "$install_dir" ]; then
return 1
fi
printf '%s\n' "$command_path"
}
# Asks the validated safe-chain binary for its install directory via get-install-dir.
# Returns nothing if the command is unavailable or the lookup fails.
get_reported_install_dir() {
safe_chain_path=$(get_validated_safe_chain_command_path || true)
if [ -z "$safe_chain_path" ]; then
return 1
fi
install_dir=$("$safe_chain_path" get-install-dir 2>/dev/null || true)
if [ -n "$install_dir" ]; then
printf '%s\n' "$install_dir"
return 0
fi
return 1
}
# Locates the installed safe-chain binary to use for teardown.
# Checks the discovered install dir first, then falls back to a validated PATH entry.
find_installed_safe_chain_binary() {
dot_safe_chain="$1"
safe_chain_location="$dot_safe_chain/bin/safe-chain"
if [ -x "$safe_chain_location" ]; then
printf '%s\n' "$safe_chain_location"
return 0
fi
command_path=$(get_validated_safe_chain_command_path || true)
if [ -n "$command_path" ]; then
printf '%s\n' "$command_path"
return 0
fi
return 1
}
# Runs safe-chain teardown before removing files.
# Continues with uninstall even if teardown is unavailable or fails.
run_safe_chain_teardown() {
safe_chain_command="$1"
if [ -z "$safe_chain_command" ]; then
warn "safe-chain command not found. Proceeding with uninstallation."
return
fi
info "Running safe-chain teardown..."
"$safe_chain_command" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
}
# Check and uninstall npm global package if present # Check and uninstall npm global package if present
remove_npm_installation() { remove_npm_installation() {
if ! command_exists npm; then if ! command_exists npm; then
@ -139,17 +291,9 @@ remove_nvm_installation() {
# Main uninstallation # Main uninstallation
main() { main() {
SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" DOT_SAFE_CHAIN=$(get_install_dir)
SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true)
if [ -x "$SAFE_CHAIN_LOCATION" ]; then run_safe_chain_teardown "$SAFE_CHAIN_COMMAND"
info "Running safe-chain teardown..."
"$SAFE_CHAIN_LOCATION" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
elif command_exists safe-chain; then
info "Running safe-chain teardown..."
safe-chain teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
else
warn "safe-chain command not found. Proceeding with uninstallation."
fi
# Check for existing safe-chain installation through nvm, volta, or npm # Check for existing safe-chain installation through nvm, volta, or npm
remove_npm_installation remove_npm_installation

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,16 @@
#!/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";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
initializePackageManager("uvx");
(async () => {
// Pass through only user-supplied uvx args
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -1,5 +1,11 @@
#!/usr/bin/env node #!/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 chalk from "chalk";
import { ui } from "../src/environment/userInteraction.js"; import { ui } from "../src/environment/userInteraction.js";
import { setup } from "../src/shell-integration/setup.js"; import { setup } from "../src/shell-integration/setup.js";
@ -15,7 +21,8 @@ import { main } from "../src/main.js";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import fs from "fs"; 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} */ /** @type {string} */
// This checks the current file's dirname in a way that's compatible with: // This checks the current file's dirname in a way that's compatible with:
@ -67,6 +74,17 @@ if (tool) {
teardownDirectories(); teardownDirectories();
} else if (command === "setup-ci") { } else if (command === "setup-ci") {
setupCi(); setupCi();
} else if (command === "get-install-dir") {
const installDir = getInstalledSafeChainDir();
if (!installDir) {
ui.writeError(
"Install directory is only available for packaged safe-chain binaries.",
);
process.exit(1);
}
ui.writeInformation(installDir);
process.exit(0);
} else if (command === "--version" || command === "-v" || command === "-v") { } else if (command === "--version" || command === "-v" || command === "-v") {
(async () => { (async () => {
ui.writeInformation(`Current safe-chain version: ${await getVersion()}`); ui.writeInformation(`Current safe-chain version: ${await getVersion()}`);
@ -88,7 +106,7 @@ function writeHelp() {
ui.writeInformation( ui.writeInformation(
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
"teardown", "teardown",
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan( )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("get-install-dir")}, ${chalk.cyan("help")}, ${chalk.cyan(
"--version", "--version",
)}`, )}`,
); );
@ -96,7 +114,7 @@ function writeHelp() {
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(
"safe-chain setup", "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( ui.writeInformation(
`- ${chalk.cyan( `- ${chalk.cyan(
@ -108,6 +126,11 @@ function writeHelp() {
"safe-chain setup-ci", "safe-chain setup-ci",
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`, )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`,
); );
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain get-install-dir",
)}: Print the install directory for packaged safe-chain binaries.`,
);
ui.writeInformation( ui.writeInformation(
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan( `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
"-v", "-v",

View file

@ -13,15 +13,19 @@
"aikido-yarn": "bin/aikido-yarn.js", "aikido-yarn": "bin/aikido-yarn.js",
"aikido-pnpm": "bin/aikido-pnpm.js", "aikido-pnpm": "bin/aikido-pnpm.js",
"aikido-pnpx": "bin/aikido-pnpx.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-bun": "bin/aikido-bun.js",
"aikido-bunx": "bin/aikido-bunx.js", "aikido-bunx": "bin/aikido-bunx.js",
"aikido-uv": "bin/aikido-uv.js", "aikido-uv": "bin/aikido-uv.js",
"aikido-uvx": "bin/aikido-uvx.js",
"aikido-pip": "bin/aikido-pip.js", "aikido-pip": "bin/aikido-pip.js",
"aikido-pip3": "bin/aikido-pip3.js", "aikido-pip3": "bin/aikido-pip3.js",
"aikido-python": "bin/aikido-python.js", "aikido-python": "bin/aikido-python.js",
"aikido-python3": "bin/aikido-python3.js", "aikido-python3": "bin/aikido-python3.js",
"aikido-poetry": "bin/aikido-poetry.js", "aikido-poetry": "bin/aikido-poetry.js",
"aikido-pipx": "bin/aikido-pipx.js", "aikido-pipx": "bin/aikido-pipx.js",
"aikido-pdm": "bin/aikido-pdm.js",
"safe-chain": "bin/safe-chain.js" "safe-chain": "bin/safe-chain.js"
}, },
"type": "module", "type": "module",
@ -36,9 +40,8 @@
"keywords": [], "keywords": [],
"author": "Aikido Security", "author": "Aikido Security",
"license": "AGPL-3.0-or-later", "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, 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": { "dependencies": {
"archiver": "^7.0.1",
"certifi": "14.5.15", "certifi": "14.5.15",
"chalk": "5.4.1", "chalk": "5.4.1",
"https-proxy-agent": "7.0.6", "https-proxy-agent": "7.0.6",
@ -49,7 +52,6 @@
"semver": "7.7.2" "semver": "7.7.2"
}, },
"devDependencies": { "devDependencies": {
"@types/archiver": "^7.0.0",
"@types/ini": "^4.1.1", "@types/ini": "^4.1.1",
"@types/make-fetch-happen": "^10.0.4", "@types/make-fetch-happen": "^10.0.4",
"@types/node": "^18.19.130", "@types/node": "^18.19.130",

View file

@ -3,14 +3,22 @@ import {
getEcoSystem, getEcoSystem,
ECOSYSTEM_JS, ECOSYSTEM_JS,
ECOSYSTEM_PY, ECOSYSTEM_PY,
getMalwareListBaseUrl,
} from "../config/settings.js"; } from "../config/settings.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
const malwareDatabaseUrls = { const malwareDatabasePaths = {
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json", [ECOSYSTEM_JS]: "malware_predictions.json",
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json", [ECOSYSTEM_PY]: "malware_pypi.json",
}; };
const newPackagesListPaths = {
[ECOSYSTEM_JS]: "releases/npm.json",
[ECOSYSTEM_PY]: "releases/pypi.json",
};
const DEFAULT_FETCH_RETRY_ATTEMPTS = 4;
/** /**
* @typedef {Object} MalwarePackage * @typedef {Object} MalwarePackage
* @property {string} package_name * @property {string} package_name
@ -18,18 +26,26 @@ const malwareDatabaseUrls = {
* @property {string} reason * @property {string} reason
*/ */
/**
* @typedef {Object} NewPackageEntry
* @property {string} [source]
* @property {string} package_name
* @property {string} version
* @property {number} released_on - Unix timestamp (seconds)
* @property {number} scraped_on - Unix timestamp (seconds)
*/
/** /**
* @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>} * @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>}
*/ */
export async function fetchMalwareDatabase() { export async function fetchMalwareDatabase() {
const numberOfAttempts = 4;
return retry(async () => { return retry(async () => {
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
const malwareDatabaseUrl = const baseUrl = getMalwareListBaseUrl();
malwareDatabaseUrls[ const path = malwareDatabasePaths[
/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
]; ];
const malwareDatabaseUrl = `${baseUrl}/${path}`;
const response = await fetch(malwareDatabaseUrl); const response = await fetch(malwareDatabaseUrl);
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
@ -46,21 +62,20 @@ export async function fetchMalwareDatabase() {
} catch (/** @type {any} */ error) { } catch (/** @type {any} */ error) {
throw new Error(`Error parsing malware database: ${error.message}`); throw new Error(`Error parsing malware database: ${error.message}`);
} }
}, numberOfAttempts); }, DEFAULT_FETCH_RETRY_ATTEMPTS);
} }
/** /**
* @returns {Promise<string | undefined>} * @returns {Promise<string | undefined>}
*/ */
export async function fetchMalwareDatabaseVersion() { export async function fetchMalwareDatabaseVersion() {
const numberOfAttempts = 4;
return retry(async () => { return retry(async () => {
const ecosystem = getEcoSystem(); const ecosystem = getEcoSystem();
const malwareDatabaseUrl = const baseUrl = getMalwareListBaseUrl();
malwareDatabaseUrls[ const path = malwareDatabasePaths[
/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem) /** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
]; ];
const malwareDatabaseUrl = `${baseUrl}/${path}`;
const response = await fetch(malwareDatabaseUrl, { const response = await fetch(malwareDatabaseUrl, {
method: "HEAD", method: "HEAD",
}); });
@ -71,7 +86,67 @@ export async function fetchMalwareDatabaseVersion() {
); );
} }
return response.headers.get("etag") || undefined; return response.headers.get("etag") || undefined;
}, numberOfAttempts); }, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* @returns {Promise<{newPackagesList: NewPackageEntry[], version: string | undefined}>}
*/
export async function fetchNewPackagesList() {
return retry(async () => {
const ecosystem = getEcoSystem();
const baseUrl = getMalwareListBaseUrl();
const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
if (!path) {
return { newPackagesList: [], version: undefined };
}
const url = `${baseUrl}/${path}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Error fetching ${ecosystem} new packages list: ${response.statusText}`
);
}
try {
const newPackagesList = await response.json();
return {
newPackagesList,
version: response.headers.get("etag") || undefined,
};
} catch (/** @type {any} */ error) {
throw new Error(`Error parsing new packages list: ${error.message}`);
}
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* @returns {Promise<string | undefined>}
*/
export async function fetchNewPackagesListVersion() {
return retry(async () => {
const ecosystem = getEcoSystem();
const baseUrl = getMalwareListBaseUrl();
const path = newPackagesListPaths[/** @type {keyof typeof newPackagesListPaths} */ (ecosystem)];
if (!path) {
return undefined;
}
const url = `${baseUrl}/${path}`;
const response = await fetch(url, { method: "HEAD" });
if (!response.ok) {
throw new Error(
`Error fetching ${ecosystem} new packages list version: ${response.statusText}`
);
}
return response.headers.get("etag") || undefined;
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
} }
/** /**
@ -91,7 +166,7 @@ async function retry(func, attempts) {
return await func(); return await func();
} catch (error) { } catch (error) {
ui.writeVerbose( ui.writeVerbose(
"An error occurred while trying to download the Aikido Malware database", "An error occurred while trying to download Aikido data",
error error
); );
lastError = error; lastError = error;

View file

@ -3,6 +3,7 @@ import assert from "node:assert";
describe("aikido API", async () => { describe("aikido API", async () => {
const mockFetch = mock.fn(); const mockFetch = mock.fn();
let ecosystem = "js";
mock.module("make-fetch-happen", { mock.module("make-fetch-happen", {
defaultExport: mockFetch, defaultExport: mockFetch,
@ -18,17 +19,23 @@ describe("aikido API", async () => {
mock.module("../config/settings.js", { mock.module("../config/settings.js", {
namedExports: { namedExports: {
getEcoSystem: () => "js", getEcoSystem: () => ecosystem,
ECOSYSTEM_JS: "js", ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py", ECOSYSTEM_PY: "py",
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
}, },
}); });
const { fetchMalwareDatabase, fetchMalwareDatabaseVersion } = const {
await import("./aikido.js"); fetchMalwareDatabase,
fetchMalwareDatabaseVersion,
fetchNewPackagesList,
fetchNewPackagesListVersion,
} = await import("./aikido.js");
beforeEach(() => { beforeEach(() => {
mockFetch.mock.resetCalls(); mockFetch.mock.resetCalls();
ecosystem = "js";
}); });
describe("fetchMalwareDatabase", () => { describe("fetchMalwareDatabase", () => {
@ -130,4 +137,95 @@ describe("aikido API", async () => {
assert.strictEqual(result, '"final-etag"'); assert.strictEqual(result, '"final-etag"');
}); });
}); });
describe("fetchNewPackagesList", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
const releases = [
{
package_name: "fresh-pkg",
version: "1.0.0",
released_on: 123,
},
];
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
json: async () => releases,
headers: { get: () => '"etag-new-packages"' },
}));
const result = await fetchNewPackagesList();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.strictEqual(
mockFetch.mock.calls[0].arguments[0],
"https://malware-list.aikido.dev/releases/npm.json"
);
assert.deepStrictEqual(result.newPackagesList, releases);
assert.strictEqual(result.version, '"etag-new-packages"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Network error");
});
await assert.rejects(() => fetchNewPackagesList(), {
message: "Network error",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
it("should return an empty list without fetching for unsupported ecosystems", async () => {
ecosystem = "ruby";
const result = await fetchNewPackagesList();
assert.strictEqual(mockFetch.mock.calls.length, 0);
assert.deepStrictEqual(result.newPackagesList, []);
assert.strictEqual(result.version, undefined);
});
it("should return undefined version without fetching for unsupported ecosystems", async () => {
ecosystem = "ruby";
const result = await fetchNewPackagesListVersion();
assert.strictEqual(mockFetch.mock.calls.length, 0);
assert.strictEqual(result, undefined);
});
});
describe("fetchNewPackagesListVersion", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
headers: { get: () => '"new-packages-etag"' },
}));
const result = await fetchNewPackagesListVersion();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.strictEqual(
mockFetch.mock.calls[0].arguments[0],
"https://malware-list.aikido.dev/releases/npm.json"
);
assert.deepStrictEqual(mockFetch.mock.calls[0].arguments[1], {
method: "HEAD",
});
assert.strictEqual(result, '"new-packages-etag"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Connection refused");
});
await assert.rejects(() => fetchNewPackagesListVersion(), {
message: "Connection refused",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
});
}); });

View file

@ -1,12 +1,13 @@
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
/** /**
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined}} * @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, malwareListBaseUrl: string | undefined}}
*/ */
const state = { const state = {
loggingLevel: undefined, loggingLevel: undefined,
skipMinimumPackageAge: undefined, skipMinimumPackageAge: undefined,
minimumPackageAgeHours: undefined, minimumPackageAgeHours: undefined,
malwareListBaseUrl: undefined,
}; };
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-"; const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
@ -20,6 +21,7 @@ export function initializeCliArguments(args) {
state.loggingLevel = undefined; state.loggingLevel = undefined;
state.skipMinimumPackageAge = undefined; state.skipMinimumPackageAge = undefined;
state.minimumPackageAgeHours = undefined; state.minimumPackageAgeHours = undefined;
state.malwareListBaseUrl = undefined;
const safeChainArgs = []; const safeChainArgs = [];
const remainingArgs = []; const remainingArgs = [];
@ -35,6 +37,7 @@ export function initializeCliArguments(args) {
setLoggingLevel(safeChainArgs); setLoggingLevel(safeChainArgs);
setSkipMinimumPackageAge(safeChainArgs); setSkipMinimumPackageAge(safeChainArgs);
setMinimumPackageAgeHours(safeChainArgs); setMinimumPackageAgeHours(safeChainArgs);
setMalwareListBaseUrl(safeChainArgs);
checkDeprecatedPythonFlag(args); checkDeprecatedPythonFlag(args);
return remainingArgs; return remainingArgs;
} }
@ -109,6 +112,26 @@ export function getMinimumPackageAgeHours() {
return state.minimumPackageAgeHours; return state.minimumPackageAgeHours;
} }
/**
* @param {string[]} args
* @returns {void}
*/
function setMalwareListBaseUrl(args) {
const argName = SAFE_CHAIN_ARG_PREFIX + "malware-list-base-url=";
const value = getLastArgEqualsValue(args, argName);
if (value) {
state.malwareListBaseUrl = value;
}
}
/**
* @returns {string | undefined}
*/
export function getMalwareListBaseUrl() {
return state.malwareListBaseUrl;
}
/** /**
* @param {string[]} args * @param {string[]} args
* @param {string} flagName * @param {string} flagName

View file

@ -3,6 +3,7 @@ import path from "path";
import os from "os"; import os from "os";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { getEcoSystem } from "./settings.js"; import { getEcoSystem } from "./settings.js";
import { getSafeChainBaseDir } from "./safeChainDir.js";
/** /**
* @typedef {Object} SafeChainConfig * @typedef {Object} SafeChainConfig
@ -10,6 +11,7 @@ import { getEcoSystem } from "./settings.js";
* We cannot trust the input and should add the necessary validations * We cannot trust the input and should add the necessary validations
* @property {unknown | Number} scanTimeout * @property {unknown | Number} scanTimeout
* @property {unknown | Number} minimumPackageAgeHours * @property {unknown | Number} minimumPackageAgeHours
* @property {unknown | string} malwareListBaseUrl
* @property {unknown | SafeChainRegistryConfiguration} npm * @property {unknown | SafeChainRegistryConfiguration} npm
* @property {unknown | SafeChainRegistryConfiguration} pip * @property {unknown | SafeChainRegistryConfiguration} pip
* *
@ -84,6 +86,18 @@ export function getMinimumPackageAgeHours() {
return undefined; return undefined;
} }
/**
* Gets the malware list base URL from config file only
* @returns {string | undefined}
*/
export function getMalwareListBaseUrl() {
const config = readConfigFile();
if (config.malwareListBaseUrl && typeof config.malwareListBaseUrl === "string") {
return config.malwareListBaseUrl;
}
return undefined;
}
/** /**
* Gets the custom npm registries from the config file (format parsing only, no validation) * Gets the custom npm registries from the config file (format parsing only, no validation)
* @returns {string[]} * @returns {string[]}
@ -129,18 +143,21 @@ export function getPipCustomRegistries() {
} }
/** /**
* Gets the minimum package age exclusions from the config file * Gets the minimum package age exclusions from the config file for the current ecosystem
* @returns {string[]} * @returns {string[]}
*/ */
export function getNpmMinimumPackageAgeExclusions() { export function getMinimumPackageAgeExclusions() {
const config = readConfigFile(); const config = readConfigFile();
const ecosystem = getEcoSystem();
const registryConfig = ecosystem === "py" ? config.pip : config.npm;
if (!config || !config.npm) { if (!config || !registryConfig) {
return []; return [];
} }
const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm); const typedRegistryConfig =
const exclusions = npmConfig.minimumPackageAgeExclusions; /** @type {SafeChainRegistryConfiguration} */ (registryConfig);
const exclusions = typedRegistryConfig.minimumPackageAgeExclusions;
if (!Array.isArray(exclusions)) { if (!Array.isArray(exclusions)) {
return []; return [];
@ -211,6 +228,7 @@ function readConfigFile() {
const emptyConfig = { const emptyConfig = {
scanTimeout: undefined, scanTimeout: undefined,
minimumPackageAgeHours: undefined, minimumPackageAgeHours: undefined,
malwareListBaseUrl: undefined,
npm: { npm: {
customRegistries: undefined, customRegistries: undefined,
}, },
@ -248,6 +266,24 @@ function getDatabaseVersionPath() {
return path.join(aikidoDir, `version_${ecosystem}.txt`); return path.join(aikidoDir, `version_${ecosystem}.txt`);
} }
/**
* @returns {string}
*/
export function getNewPackagesListPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_${ecosystem}.json`);
}
/**
* @returns {string}
*/
export function getNewPackagesListVersionPath() {
const safeChainDir = getSafeChainDirectory();
const ecosystem = getEcoSystem();
return path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`);
}
/** /**
* @returns {string} * @returns {string}
*/ */
@ -268,9 +304,8 @@ function getConfigFilePath() {
/** /**
* @returns {string} * @returns {string}
*/ */
function getSafeChainDirectory() { export function getSafeChainDirectory() {
const homeDir = os.homedir(); const safeChainDir = getSafeChainBaseDir();
const safeChainDir = path.join(homeDir, ".safe-chain");
if (!fs.existsSync(safeChainDir)) { if (!fs.existsSync(safeChainDir)) {
fs.mkdirSync(safeChainDir, { recursive: true }); fs.mkdirSync(safeChainDir, { recursive: true });

View file

@ -41,6 +41,17 @@ export function getLoggingLevel() {
* Example: "react,@aikidosec/safe-chain,lodash" * Example: "react,@aikidosec/safe-chain,lodash"
* @returns {string | undefined} * @returns {string | undefined}
*/ */
export function getNpmMinimumPackageAgeExclusions() { export function getMinimumPackageAgeExclusions() {
return process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS; return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS ||
process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS;
}
/**
* Gets the malware list base URL from environment variable
* Expected format: full URL without trailing slash
* Example: "https://malware-list.aikido.dev"
* @returns {string | undefined}
*/
export function getMalwareListBaseUrl() {
return process.env.SAFE_CHAIN_MALWARE_LIST_BASE_URL;
} }

View file

@ -0,0 +1,71 @@
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { getInstalledSafeChainDir } from "../installLocation.js";
/**
* @returns {string}
*/
export function getSafeChainBaseDir() {
return getInstalledSafeChainDir() ?? path.join(os.homedir(), ".safe-chain");
}
/**
* @returns {string}
*/
export function getBinDir() {
return path.join(getSafeChainBaseDir(), "bin");
}
/**
* @returns {string}
*/
export function getShimsDir() {
return path.join(getSafeChainBaseDir(), "shims");
}
/**
* @returns {string}
*/
export function getScriptsDir() {
return path.join(getSafeChainBaseDir(), "scripts");
}
/**
* @returns {string}
*/
export function getCertsDir() {
return path.join(getSafeChainBaseDir(), "certs");
}
/**
* Resolves the directory of the calling module.
* Falls back to __dirname when import.meta.url is unavailable (pkg CJS binary).
* @param {string | undefined} moduleUrl
* @returns {string}
*/
function resolveModuleDir(moduleUrl) {
if (moduleUrl) {
return path.dirname(fileURLToPath(moduleUrl));
}
// eslint-disable-next-line no-undef
return __dirname;
}
/**
* @param {string | undefined} moduleUrl
* @param {string} fileName
* @returns {string}
*/
export function getStartupScriptSourcePath(moduleUrl, fileName) {
return path.join(resolveModuleDir(moduleUrl), "startup-scripts", fileName);
}
/**
* @param {string | undefined} moduleUrl
* @param {string} fileName
* @returns {string}
*/
export function getPathWrapperTemplatePath(moduleUrl, fileName) {
return path.join(resolveModuleDir(moduleUrl), "path-wrappers", "templates", fileName);
}

View file

@ -1,6 +1,7 @@
import * as cliArguments from "./cliArguments.js"; import * as cliArguments from "./cliArguments.js";
import * as configFile from "./configFile.js"; import * as configFile from "./configFile.js";
import * as environmentVariables from "./environmentVariables.js"; import * as environmentVariables from "./environmentVariables.js";
import { ui } from "../environment/userInteraction.js";
export const LOGGING_SILENT = "silent"; export const LOGGING_SILENT = "silent";
export const LOGGING_NORMAL = "normal"; export const LOGGING_NORMAL = "normal";
@ -188,13 +189,59 @@ function parseExclusionsFromEnv(envValue) {
* Gets the minimum package age exclusions from both environment variable and config file (merged) * Gets the minimum package age exclusions from both environment variable and config file (merged)
* @returns {string[]} * @returns {string[]}
*/ */
export function getNpmMinimumPackageAgeExclusions() { export function getMinimumPackageAgeExclusions() {
const envExclusions = parseExclusionsFromEnv( const envExclusions = parseExclusionsFromEnv(
environmentVariables.getNpmMinimumPackageAgeExclusions() environmentVariables.getMinimumPackageAgeExclusions()
); );
const configExclusions = configFile.getNpmMinimumPackageAgeExclusions(); const configExclusions = configFile.getMinimumPackageAgeExclusions();
// Merge both sources and remove duplicates // Merge both sources and remove duplicates
const allExclusions = [...envExclusions, ...configExclusions]; const allExclusions = [...envExclusions, ...configExclusions];
return [...new Set(allExclusions)]; return [...new Set(allExclusions)];
} }
/**
* Gets the malware list base URL with priority: CLI argument > environment variable > config file > default
* @returns {string}
*/
export function getMalwareListBaseUrl() {
// Priority 1: CLI argument
const cliValue = cliArguments.getMalwareListBaseUrl();
if (cliValue) {
const url = removeTrailingSlashes(cliValue);
ui.writeVerbose(`Fetching malware lists from ${url} as defined by CLI argument --safe-chain-malware-list-base-url`);
return url;
}
// Priority 2: Environment variable
const envValue = environmentVariables.getMalwareListBaseUrl();
if (envValue) {
const url = removeTrailingSlashes(envValue);
ui.writeVerbose(`Fetching malware lists from ${url} as defined by environment variable SAFE_CHAIN_MALWARE_LIST_BASE_URL`);
return url;
}
// Priority 3: Config file
const configValue = configFile.getMalwareListBaseUrl();
if (configValue) {
const url = removeTrailingSlashes(configValue);
ui.writeVerbose(`Fetching malware lists from ${url} as defined by config file (malwareListBaseUrl)`);
return url;
}
// Default
return removeTrailingSlashes("https://malware-list.aikido.dev");
}
/**
* Removes trailing slashes from a URL-like string.
* @param {string} value
* @returns {string}
*/
function removeTrailingSlashes(value) {
if (!value || typeof value !== "string") {
return value;
}
return value.replace(/\/+$/, "");
}

View file

@ -14,7 +14,11 @@ mock.module("fs", {
const { const {
getNpmCustomRegistries, getNpmCustomRegistries,
getPipCustomRegistries, getPipCustomRegistries,
getNpmMinimumPackageAgeExclusions, getMinimumPackageAgeExclusions,
getMalwareListBaseUrl,
setEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
getLoggingLevel, getLoggingLevel,
LOGGING_SILENT, LOGGING_SILENT,
LOGGING_NORMAL, LOGGING_NORMAL,
@ -367,13 +371,18 @@ describe("getLoggingLevel", () => {
}); });
}); });
describe("getNpmMinimumPackageAgeExclusions", () => { describe("getMinimumPackageAgeExclusions", () => {
let originalEnv; let originalEnv;
const envVarName = "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS"; let originalLegacyEnv;
const envVarName = "SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS";
const legacyEnvVarName = "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS";
beforeEach(() => { beforeEach(() => {
originalEnv = process.env[envVarName]; originalEnv = process.env[envVarName];
originalLegacyEnv = process.env[legacyEnvVarName];
delete process.env[envVarName]; delete process.env[envVarName];
delete process.env[legacyEnvVarName];
setEcoSystem(ECOSYSTEM_JS);
}); });
afterEach(() => { afterEach(() => {
@ -382,13 +391,18 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
} else { } else {
delete process.env[envVarName]; delete process.env[envVarName];
} }
if (originalLegacyEnv !== undefined) {
process.env[legacyEnvVarName] = originalLegacyEnv;
} else {
delete process.env[legacyEnvVarName];
}
configFileContent = undefined; configFileContent = undefined;
}); });
it("should return empty array when no exclusions configured", () => { it("should return empty array when no exclusions configured", () => {
configFileContent = undefined; configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []); assert.deepStrictEqual(exclusions, []);
}); });
@ -400,7 +414,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
}, },
}); });
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["react", "@aikidosec/safe-chain"]); assert.deepStrictEqual(exclusions, ["react", "@aikidosec/safe-chain"]);
}); });
@ -409,7 +423,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
process.env[envVarName] = "lodash,express,@types/node"; process.env[envVarName] = "lodash,express,@types/node";
configFileContent = undefined; configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "express", "@types/node"]); assert.deepStrictEqual(exclusions, ["lodash", "express", "@types/node"]);
}); });
@ -422,7 +436,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
}, },
}); });
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]); assert.deepStrictEqual(exclusions, ["lodash", "react"]);
}); });
@ -435,7 +449,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
}, },
}); });
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react", "express"]); assert.deepStrictEqual(exclusions, ["lodash", "react", "express"]);
}); });
@ -444,7 +458,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
process.env[envVarName] = " lodash , react "; process.env[envVarName] = " lodash , react ";
configFileContent = undefined; configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]); assert.deepStrictEqual(exclusions, ["lodash", "react"]);
}); });
@ -456,7 +470,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
}, },
}); });
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["@babel/core", "@types/react"]); assert.deepStrictEqual(exclusions, ["@babel/core", "@types/react"]);
}); });
@ -465,7 +479,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
process.env[envVarName] = "lodash,,react,"; process.env[envVarName] = "lodash,,react,";
configFileContent = undefined; configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]); assert.deepStrictEqual(exclusions, ["lodash", "react"]);
}); });
@ -474,7 +488,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
process.env[envVarName] = ""; process.env[envVarName] = "";
configFileContent = undefined; configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []); assert.deepStrictEqual(exclusions, []);
}); });
@ -483,7 +497,7 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
process.env[envVarName] = " , , "; process.env[envVarName] = " , , ";
configFileContent = undefined; configFileContent = undefined;
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []); assert.deepStrictEqual(exclusions, []);
}); });
@ -495,8 +509,139 @@ describe("getNpmMinimumPackageAgeExclusions", () => {
}, },
}); });
const exclusions = getNpmMinimumPackageAgeExclusions(); const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["react", "lodash"]); assert.deepStrictEqual(exclusions, ["react", "lodash"]);
}); });
it("should fall back to the legacy npm environment variable", () => {
process.env[legacyEnvVarName] = "lodash,react";
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
it("should read exclusions from the python config when the current ecosystem is py", () => {
setEcoSystem(ECOSYSTEM_PY);
configFileContent = JSON.stringify({
pip: {
minimumPackageAgeExclusions: ["requests", "urllib3"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["requests", "urllib3"]);
});
});
describe("getMalwareListBaseUrl", () => {
let originalEnv;
const envVarName = "SAFE_CHAIN_MALWARE_LIST_BASE_URL";
beforeEach(() => {
originalEnv = process.env[envVarName];
delete process.env[envVarName];
// Reset CLI arguments state
initializeCliArguments([]);
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env[envVarName] = originalEnv;
} else {
delete process.env[envVarName];
}
configFileContent = undefined;
});
it("should return default URL when nothing is configured", () => {
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://malware-list.aikido.dev");
});
it("should trim trailing slash from CLI argument", () => {
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com/"]);
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
it("should trim trailing slash from environment variable", () => {
process.env[envVarName] = "https://env-mirror.com/";
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://env-mirror.com");
});
it("should trim trailing slash from config file value", () => {
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com/",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://config-mirror.com");
});
it("should return CLI argument value with highest priority", () => {
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
it("should return environment variable value when no CLI argument", () => {
process.env[envVarName] = "https://env-mirror.com";
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://env-mirror.com");
});
it("should return config file value when no CLI or env", () => {
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://config-mirror.com");
});
it("should prioritize CLI over environment variable", () => {
process.env[envVarName] = "https://env-mirror.com";
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
it("should prioritize environment variable over config file", () => {
process.env[envVarName] = "https://env-mirror.com";
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://env-mirror.com");
});
it("should prioritize CLI over config file", () => {
initializeCliArguments(["--safe-chain-malware-list-base-url=https://cli-mirror.com"]);
configFileContent = JSON.stringify({
malwareListBaseUrl: "https://config-mirror.com",
});
const url = getMalwareListBaseUrl();
assert.strictEqual(url, "https://cli-mirror.com");
});
}); });

View file

@ -0,0 +1,42 @@
import path from "path";
/** @type {NodeJS.Process & { pkg?: unknown }} */
const processWithPkg = process;
/**
* @param {string} executablePath
* @returns {string | undefined}
*/
export function deriveInstallDirFromExecutablePath(executablePath) {
if (!executablePath) {
return undefined;
}
const pathLibrary = executablePath.includes("\\") ? path.win32 : path.posix;
const executableDir = pathLibrary.dirname(executablePath);
if (pathLibrary.basename(executableDir) !== "bin") {
return undefined;
}
return pathLibrary.dirname(executableDir);
}
/**
* Returns the install directory for a packaged safe-chain binary.
* Custom installation directories only apply to packaged binary installs.
* For npm/global/dev-script executions this intentionally returns undefined,
* which causes callers to fall back to the default ~/.safe-chain layout.
*
* @param {{ isPackaged?: boolean, executablePath?: string }} [options]
* @returns {string | undefined}
*/
export function getInstalledSafeChainDir(options = {}) {
const isPackaged = options.isPackaged ?? Boolean(processWithPkg.pkg);
if (!isPackaged) {
return undefined;
}
return deriveInstallDirFromExecutablePath(
options.executablePath ?? process.execPath,
);
}

View file

@ -0,0 +1,51 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import {
deriveInstallDirFromExecutablePath,
getInstalledSafeChainDir,
} from "./installLocation.js";
describe("deriveInstallDirFromExecutablePath", () => {
it("derives the install dir from a Unix binary path", () => {
assert.strictEqual(
deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/bin/safe-chain"),
"/usr/local/.safe-chain",
);
});
it("derives the install dir from a Windows binary path", () => {
assert.strictEqual(
deriveInstallDirFromExecutablePath("C:\\ProgramData\\safe-chain\\bin\\safe-chain.exe"),
"C:\\ProgramData\\safe-chain",
);
});
it("returns undefined when the executable is not inside a bin directory", () => {
assert.strictEqual(
deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/safe-chain"),
undefined,
);
});
});
describe("getInstalledSafeChainDir", () => {
it("returns undefined for non-packaged executions", () => {
assert.strictEqual(
getInstalledSafeChainDir({
isPackaged: false,
executablePath: "/usr/local/.safe-chain/bin/safe-chain",
}),
undefined,
);
});
it("returns the install dir for packaged executions", () => {
assert.strictEqual(
getInstalledSafeChainDir({
isPackaged: true,
executablePath: "/usr/local/.safe-chain/bin/safe-chain",
}),
"/usr/local/.safe-chain",
);
});
});

View file

@ -1,125 +0,0 @@
import { createWriteStream, createReadStream } from "fs";
import { createHash } from "crypto";
import { pipeline } from "stream/promises";
import fetch from "make-fetch-happen";
const ULTIMATE_VERSION = "v1.0.0";
export const DOWNLOAD_URLS = {
win32: {
x64: {
url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-amd64.msi`,
checksum:
"sha256:c6a36f9b8e55ab6b7e8742cbabc4469d85809237c0f5e6c21af20b36c416ee1d",
},
arm64: {
url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-arm64.msi`,
checksum:
"sha256:46acd1af6a9938ea194c8ee8b34ca9b47c8de22e088a0791f3c0751dd6239c90",
},
},
darwin: {
x64: {
url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-amd64.pkg`,
checksum:
"sha256:bb1829e8ca422e885baf37bef08dcbe7df7a30f248e2e89c4071564f7d4f3396",
},
arm64: {
url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-arm64.pkg`,
checksum:
"sha256:7fe4a785709911cc366d8224b4c290677573b8c4833bd9054768299e55c5f0ed",
},
},
};
/**
* Builds the download URL for the SafeChain Agent installer.
* @param {string} fileName
*/
export function getAgentDownloadUrl(fileName) {
return `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/${fileName}`;
}
/**
* Downloads a file from a URL to a local path.
* @param {string} url
* @param {string} destPath
*/
export async function downloadFile(url, destPath) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Download failed: ${response.statusText}`);
}
await pipeline(response.body, createWriteStream(destPath));
}
/**
* Returns the current agent version.
*/
export function getAgentVersion() {
return ULTIMATE_VERSION;
}
/**
* Returns download info (url, checksum) for the current OS and architecture.
* @returns {{ url: string, checksum: string } | null}
*/
export function getDownloadInfoForCurrentPlatform() {
const platform = process.platform;
const arch = process.arch;
if (!Object.hasOwn(DOWNLOAD_URLS, platform)) {
return null;
}
const platformUrls =
DOWNLOAD_URLS[/** @type {keyof typeof DOWNLOAD_URLS} */ (platform)];
if (!Object.hasOwn(platformUrls, arch)) {
return null;
}
return platformUrls[/** @type {keyof typeof platformUrls} */ (arch)];
}
/**
* Verifies the checksum of a file.
* @param {string} filePath
* @param {string} expectedChecksum - Format: "algorithm:hash" (e.g., "sha256:abc123...")
* @returns {Promise<boolean>}
*/
export async function verifyChecksum(filePath, expectedChecksum) {
const [algorithm, expected] = expectedChecksum.split(":");
const hash = createHash(algorithm);
if (filePath.includes("..")) throw new Error("Invalid file path");
const stream = createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
const actual = hash.digest("hex");
return actual === expected;
}
/**
* Downloads the SafeChain agent for the current OS/arch and verifies its checksum.
* @param {string} fileName - Destination file path
* @returns {Promise<string | null>} The file path if successful, null if no download URL for current platform
*/
export async function downloadAgentToFile(fileName) {
const info = getDownloadInfoForCurrentPlatform();
if (!info) {
return null;
}
await downloadFile(info.url, fileName);
const isValid = await verifyChecksum(fileName, info.checksum);
if (!isValid) {
throw new Error("Checksum verification failed");
}
return fileName;
}

View file

@ -1,45 +0,0 @@
import { describe, it, after } from "node:test";
import assert from "node:assert";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { unlinkSync } from "node:fs";
import {
DOWNLOAD_URLS,
downloadFile,
verifyChecksum,
} from "./downloadAgent.js";
describe("downloadAgent checksums", { timeout: 120_000 }, () => {
const downloadedFiles = [];
after(() => {
for (const file of downloadedFiles) {
try {
unlinkSync(file);
} catch {
// ignore cleanup errors
}
}
});
for (const [platform, architectures] of Object.entries(DOWNLOAD_URLS)) {
for (const [arch, { url, checksum }] of Object.entries(architectures)) {
it(`${platform}/${arch} checksum matches`, async () => {
const destPath = join(
tmpdir(),
`safe-chain-test-${platform}-${arch}-${Date.now()}`
);
downloadedFiles.push(destPath);
await downloadFile(url, destPath);
const isValid = await verifyChecksum(destPath, checksum);
assert.strictEqual(
isValid,
true,
`Checksum mismatch for ${platform}/${arch} (${url})`
);
});
}
}
});

View file

@ -1,155 +0,0 @@
import { tmpdir } from "os";
import { unlinkSync } from "fs";
import { join } from "path";
import { execSync, spawnSync } from "child_process";
import { ui } from "../environment/userInteraction.js";
import { printVerboseAndSafeSpawn } from "../utils/safeSpawn.js";
import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js";
import chalk from "chalk";
const MACOS_PKG_IDENTIFIER = "com.aikidosecurity.safechainultimate";
/**
* Checks if root privileges are available and displays error message if not.
* @param {string} command - The sudo command to show in the error message
* @returns {boolean} True if running as root, false otherwise.
*/
function requireRootPrivileges(command) {
if (isRunningAsRoot()) {
return true;
}
ui.writeError("Root privileges required.");
ui.writeInformation("Please run this command with sudo:");
ui.writeInformation(` ${command}`);
return false;
}
function isRunningAsRoot() {
const rootUserUid = 0;
return process.getuid?.() === rootUserUid;
}
export async function installOnMacOS() {
if (!requireRootPrivileges("sudo safe-chain ultimate")) {
return;
}
const pkgPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.pkg`);
ui.emptyLine();
ui.writeInformation(`📥 Downloading SafeChain Ultimate ${getAgentVersion()}`);
ui.writeVerbose(`Destination: ${pkgPath}`);
const result = await downloadAgentToFile(pkgPath);
if (!result) {
ui.writeError("No download available for this platform/architecture.");
return;
}
try {
ui.writeInformation("⚙️ Installing SafeChain Ultimate...");
await runPkgInstaller(pkgPath);
ui.emptyLine();
ui.writeInformation(
"✅ SafeChain Ultimate installed and started successfully!",
);
ui.emptyLine();
ui.writeInformation(
chalk.cyan("🔐 ") +
chalk.bold("ACTION REQUIRED: ") +
"macOS will show a popup to install our certificate.",
);
ui.writeInformation(
" " +
chalk.bold("Please accept the certificate") +
" to complete the installation.",
);
ui.emptyLine();
} finally {
ui.writeVerbose(`Cleaning up temporary file: ${pkgPath}`);
cleanup(pkgPath);
}
}
const MACOS_UNINSTALL_SCRIPT =
"/Library/Application\\ Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall";
export async function uninstallOnMacOS() {
if (!requireRootPrivileges("sudo safe-chain ultimate uninstall")) {
return;
}
ui.emptyLine();
if (!isPackageInstalled()) {
ui.writeInformation("SafeChain Ultimate is not installed.");
return;
}
ui.writeInformation("🗑️ Uninstalling SafeChain Ultimate...");
ui.writeVerbose(`Running: ${MACOS_UNINSTALL_SCRIPT}`);
const result = spawnSync(MACOS_UNINSTALL_SCRIPT, {
stdio: "inherit",
shell: true,
});
if (result.status !== 0) {
ui.writeError(
`Uninstall script failed (exit code: ${result.status}). Please try again or remove manually.`,
);
return;
}
ui.emptyLine();
ui.writeInformation("✅ SafeChain Ultimate has been uninstalled.");
ui.emptyLine();
}
function isPackageInstalled() {
try {
const output = execSync(`pkgutil --pkg-info ${MACOS_PKG_IDENTIFIER}`, {
encoding: "utf8",
stdio: "pipe",
});
return output.includes(MACOS_PKG_IDENTIFIER);
} catch {
return false;
}
}
/**
* @param {string} pkgPath
*/
async function runPkgInstaller(pkgPath) {
// Uses installer to install the package (https://ss64.com/mac/installer.html)
// Options:
// -pkg (required): The package to be installed.
// -target (required): The target volume is specified with the -target parameter.
// --> "-target /" installs to the current boot volume.
const result = await printVerboseAndSafeSpawn(
"installer",
["-pkg", pkgPath, "-target", "/"],
{
stdio: "inherit",
},
);
if (result.status !== 0) {
throw new Error(`PKG installer failed (exit code: ${result.status})`);
}
}
/**
* @param {string} pkgPath
*/
function cleanup(pkgPath) {
try {
unlinkSync(pkgPath);
} catch {
ui.writeVerbose("Failed to clean up temporary installer file.");
}
}

View file

@ -1,203 +0,0 @@
import { tmpdir } from "os";
import { unlinkSync } from "fs";
import { join } from "path";
import { execSync } from "child_process";
import { ui } from "../environment/userInteraction.js";
import { printVerboseAndSafeSpawn, safeSpawn } from "../utils/safeSpawn.js";
import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js";
const WINDOWS_SERVICE_NAME = "SafeChainUltimate";
const WINDOWS_APP_NAME = "SafeChain Ultimate";
export async function uninstallOnWindows() {
if (!(await requireAdminPrivileges())) {
return;
}
ui.emptyLine();
const productCode = getInstalledProductCode();
if (!productCode) {
ui.writeInformation("SafeChain Ultimate is not installed.");
return;
}
await stopServiceIfRunning();
ui.writeInformation("🗑️ Uninstalling SafeChain Ultimate...");
await uninstallByProductCode(productCode);
ui.emptyLine();
ui.writeInformation("✅ SafeChain Ultimate has been uninstalled.");
ui.emptyLine();
}
export async function installOnWindows() {
if (!(await requireAdminPrivileges())) {
return;
}
const msiPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.msi`);
ui.emptyLine();
ui.writeInformation(`📥 Downloading SafeChain Ultimate ${getAgentVersion()}`);
ui.writeVerbose(`Destination: ${msiPath}`);
const result = await downloadAgentToFile(msiPath);
if (!result) {
ui.writeError("No download available for this platform/architecture.");
return;
}
try {
ui.emptyLine();
await stopServiceIfRunning();
await uninstallIfInstalled();
ui.writeInformation("⚙️ Installing SafeChain Ultimate...");
await runMsiInstaller(msiPath);
ui.emptyLine();
ui.writeInformation(
"✅ SafeChain Ultimate installed and started successfully!",
);
ui.emptyLine();
} finally {
ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`);
cleanup(msiPath);
}
}
/**
* Checks if admin privileges are available and displays error message if not.
* @returns {Promise<boolean>} True if running as admin, false otherwise.
*/
async function requireAdminPrivileges() {
if (await isRunningAsAdmin()) {
return true;
}
ui.writeError("Administrator privileges required.");
ui.writeInformation(
"Please run this command in an elevated terminal (Run as Administrator).",
);
return false;
}
async function isRunningAsAdmin() {
// Uses Windows Security API to check if current process has admin privileges.
// Returns "True" or "False" as a string.
const result = await safeSpawn(
"powershell",
[
"-Command",
"([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
],
{ stdio: "pipe" },
);
return result.status === 0 && result.stdout.trim() === "True";
}
/**
* Returns the MSI product code for SafeChain Ultimate, or null if not installed.
* @returns {string | null}
*/
function getInstalledProductCode() {
// Query Win32_Product via WMI to find the installed SafeChain Agent.
// If found, outputs the product GUID (e.g., "{12345678-1234-...}") needed for msiexec uninstall.
ui.writeVerbose(`Finding product code with PowerShell`);
let productCode;
try {
productCode = execSync(
`powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='${WINDOWS_APP_NAME}'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`,
{ encoding: "utf8" },
).trim();
} catch {
return null;
}
return productCode || null;
}
/**
* @param {string} productCode
*/
async function uninstallByProductCode(productCode) {
ui.writeVerbose(`Found product code: ${productCode}`);
// Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec)
// Options:
// - /x: Uninstalls the package.
// - /qn: Specifies there's no UI during the installation process.
// - /norestart: Stops the device from restarting after the installation completes.
const uninstallResult = await printVerboseAndSafeSpawn(
"msiexec",
["/x", productCode, "/qn", "/norestart"],
{ stdio: "inherit" },
);
if (uninstallResult.status !== 0) {
throw new Error(`Uninstall failed (exit code: ${uninstallResult.status})`);
}
}
async function uninstallIfInstalled() {
const productCode = getInstalledProductCode();
if (!productCode) {
ui.writeVerbose("No existing installation found (fresh install).");
return;
}
ui.writeInformation("🗑️ Removing previous installation...");
await uninstallByProductCode(productCode);
}
/**
* @param {string} msiPath
*/
async function runMsiInstaller(msiPath) {
// Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec)
// Options:
// - /i: Specifies normal installation
// - /qn: Specifies there's no UI during the installation process.
const result = await printVerboseAndSafeSpawn(
"msiexec",
["/i", msiPath, "/qn"],
{
stdio: "inherit",
},
);
if (result.status !== 0) {
throw new Error(`MSI installer failed (exit code: ${result.status})`);
}
}
async function stopServiceIfRunning() {
ui.writeInformation("⏹️ Stopping running service...");
const result = await printVerboseAndSafeSpawn(
"net",
["stop", WINDOWS_SERVICE_NAME],
{
stdio: "pipe",
},
);
if (result.status !== 0) {
ui.writeVerbose("Service not running (will start after installation).");
}
}
/**
* @param {string} msiPath
*/
function cleanup(msiPath) {
try {
unlinkSync(msiPath);
} catch {
ui.writeVerbose("Failed to clean up temporary installer file.");
}
}

View file

@ -1,35 +0,0 @@
import { platform } from "os";
import { ui } from "../environment/userInteraction.js";
import { initializeCliArguments } from "../config/cliArguments.js";
import { installOnWindows, uninstallOnWindows } from "./installOnWindows.js";
import { installOnMacOS, uninstallOnMacOS } from "./installOnMacOS.js";
export async function uninstallUltimate() {
initializeCliArguments(process.argv);
const operatingSystem = platform();
if (operatingSystem === "win32") {
await uninstallOnWindows();
} else if (operatingSystem === "darwin") {
await uninstallOnMacOS();
} else {
ui.writeInformation(
`Uninstall is not yet supported on ${operatingSystem}.`,
);
}
}
export async function installUltimate() {
const operatingSystem = platform();
if (operatingSystem === "win32") {
await installOnWindows();
} else if (operatingSystem === "darwin") {
await installOnMacOS();
} else {
ui.writeInformation(
`${operatingSystem} is not supported yet by SafeChain's ultimate version.`,
);
}
}

View file

@ -64,7 +64,11 @@ export async function main(args) {
// Write all buffered logs // Write all buffered logs
ui.writeBufferedLogsAndStopBuffering(); ui.writeBufferedLogsAndStopBuffering();
if (!proxy.verifyNoMaliciousPackages()) { if (proxy.hasBlockedMaliciousPackages()) {
return 1;
}
if (proxy.hasBlockedMinimumAgeRequests()) {
return 1; return 1;
} }
@ -81,7 +85,7 @@ export async function main(args) {
ui.writeInformation( ui.writeInformation(
`${chalk.yellow( `${chalk.yellow(
"", "",
)} Safe-chain: Some package versions were suppressed due to minimum age requirement.`, )} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`,
); );
ui.writeInformation( ui.writeInformation(
` To disable this check, use: ${chalk.cyan( ` To disable this check, use: ${chalk.cyan(

View file

@ -13,6 +13,10 @@ import { createPipPackageManager } from "./pip/createPackageManager.js";
import { createUvPackageManager } from "./uv/createUvPackageManager.js"; import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js"; import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.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";
/** /**
* @type {{packageManagerName: PackageManager | null}} * @type {{packageManagerName: PackageManager | null}}
@ -60,10 +64,18 @@ export function initializePackageManager(packageManagerName, context) {
state.packageManagerName = createPipPackageManager(context); state.packageManagerName = createPipPackageManager(context);
} else if (packageManagerName === "uv") { } else if (packageManagerName === "uv") {
state.packageManagerName = createUvPackageManager(); state.packageManagerName = createUvPackageManager();
} else if (packageManagerName === "uvx") {
state.packageManagerName = createUvxPackageManager();
} else if (packageManagerName === "poetry") { } else if (packageManagerName === "poetry") {
state.packageManagerName = createPoetryPackageManager(); state.packageManagerName = createPoetryPackageManager();
} else if (packageManagerName === "pipx") { } else if (packageManagerName === "pipx") {
state.packageManagerName = createPipXPackageManager(); 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 { } else {
throw new Error("Unsupported package manager: " + packageManagerName); 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

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

View file

@ -0,0 +1,14 @@
import { test } from "node:test";
import assert from "node:assert";
import { createUvxPackageManager } from "./createUvxPackageManager.js";
test("createUvxPackageManager returns valid package manager interface", () => {
const pm = createUvxPackageManager();
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

@ -1,9 +1,8 @@
import forge from "node-forge"; import forge from "node-forge";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import os from "os"; import { getCertsDir } from "../config/safeChainDir.js";
const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
const ca = loadCa(); const ca = loadCa();
const certCache = new Map(); const certCache = new Map();
@ -20,7 +19,7 @@ function createKeyIdentifier(publicKey) {
} }
export function getCaCertPath() { export function getCaCertPath() {
return path.join(certFolder, "ca-cert.pem"); return path.join(getCertsDir(), "ca-cert.pem");
} }
/** /**
@ -112,6 +111,7 @@ export function generateCertForHost(hostname) {
} }
function loadCa() { function loadCa() {
const certFolder = getCertsDir();
const keyPath = path.join(certFolder, "ca-key.pem"); const keyPath = path.join(certFolder, "ca-key.pem");
const certPath = path.join(certFolder, "ca-cert.pem"); const certPath = path.join(certFolder, "ca-cert.pem");

View file

@ -0,0 +1,71 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("certUtils", () => {
let installedSafeChainDir;
beforeEach(() => {
installedSafeChainDir = undefined;
mock.module("../config/safeChainDir.js", {
namedExports: {
getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain",
getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`,
},
});
});
afterEach(() => {
mock.reset();
});
it("stores CA certificates in the packaged install dir when available", async () => {
installedSafeChainDir = "/custom/safe-chain";
mock.module("fs", {
defaultExport: {
existsSync: () => false,
mkdirSync: () => {},
writeFileSync: () => {},
},
});
mock.module("node-forge", {
defaultExport: {
pki: {
getPublicKeyFingerprint: () => "fingerprint",
rsa: {
generateKeyPair: () => ({
publicKey: "public-key",
privateKey: "private-key",
}),
},
createCertificate: () => ({
publicKey: null,
serialNumber: "",
validity: {
notBefore: new Date(),
notAfter: new Date(),
},
setSubject: () => {},
setIssuer: () => {},
setExtensions: () => {},
sign: () => {},
}),
privateKeyToPem: () => "private-key-pem",
certificateToPem: () => "certificate-pem",
},
md: {
sha1: { create: () => "sha1" },
sha256: { create: () => "sha256" },
},
},
});
const { getCaCertPath } = await import("./certUtils.js");
assert.strictEqual(
getCaCertPath(),
"/custom/safe-chain/certs/ca-cert.pem",
);
});
});

View file

@ -15,3 +15,66 @@ export function getHeaderValueAsString(headers, headerName) {
return header; return header;
} }
/**
* Returns a copy of headers without the provided header names, matched
* either exactly or case-insensitively.
*
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @param {string[]} headerNames
* @param {{ caseInsensitive?: boolean }} [options]
* @returns {NodeJS.Dict<string | string[]> | undefined}
*/
export function omitHeaders(headers, headerNames, options = {}) {
if (!headers) {
return headers;
}
const omittedHeaderNames = new Set(
options.caseInsensitive
? headerNames.map((name) => name.toLowerCase())
: headerNames
);
/** @type {NodeJS.Dict<string | string[]>} */
const filteredHeaders = {};
for (const [headerName, value] of Object.entries(headers)) {
const comparableHeaderName = options.caseInsensitive
? headerName.toLowerCase()
: headerName;
if (!omittedHeaderNames.has(comparableHeaderName)) {
filteredHeaders[headerName] = value;
}
}
return filteredHeaders;
}
/**
* Remove headers that become stale when the response body is modified.
*
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {void}
*/
export function clearCachingHeaders(headers) {
if (!headers) {
return;
}
const filteredHeaders = omitHeaders(headers, [
"etag",
"last-modified",
"cache-control",
"content-length",
]);
if (!filteredHeaders) {
return;
}
for (const key of Object.keys(headers)) {
delete headers[key];
}
Object.assign(headers, filteredHeaders);
}

View file

@ -4,7 +4,7 @@ import {
getEcoSystem, getEcoSystem,
} from "../../config/settings.js"; } from "../../config/settings.js";
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js"; import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
import { pipInterceptorForUrl } from "./pipInterceptor.js"; import { pipInterceptorForUrl } from "./pip/pipInterceptor.js";
/** /**
* @param {string} url * @param {string} url

View file

@ -10,6 +10,7 @@ import { EventEmitter } from "events";
* @typedef {Object} RequestInterceptionContext * @typedef {Object} RequestInterceptionContext
* @property {string} targetUrl * @property {string} targetUrl
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware * @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
* @property {(packageName: string, version: string, message: string) => void} blockMinimumAgeRequest
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders * @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
* @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
* @property {() => RequestInterceptionHandler} build * @property {() => RequestInterceptionHandler} build
@ -26,6 +27,12 @@ import { EventEmitter } from "events";
* @property {string} version * @property {string} version
* @property {string} targetUrl * @property {string} targetUrl
* @property {number} timestamp * @property {number} timestamp
*
* @typedef {Object} MinimumAgeRequestBlockedEvent
* @property {string} packageName
* @property {string} version
* @property {string} targetUrl
* @property {number} timestamp
*/ */
/** /**
@ -81,10 +88,7 @@ function createRequestContext(targetUrl, eventEmitter) {
* @param {string | undefined} version * @param {string | undefined} version
*/ */
function blockMalwareSetup(packageName, version) { function blockMalwareSetup(packageName, version) {
blockResponse = { blockResponse = createBlockResponse("Forbidden - blocked by safe-chain");
statusCode: 403,
message: "Forbidden - blocked by safe-chain",
};
// Emit the malwareBlocked event // Emit the malwareBlocked event
eventEmitter.emit("malwareBlocked", { eventEmitter.emit("malwareBlocked", {
@ -95,6 +99,34 @@ function createRequestContext(targetUrl, eventEmitter) {
}); });
} }
/**
* @param {string} message
*/
function blockMinimumAgeRequestSetup(
/** @type {string} */ packageName,
/** @type {string} */ version,
/** @type {string} */ message
) {
blockResponse = createBlockResponse(message);
eventEmitter.emit("minimumAgeRequestBlocked", {
packageName,
version,
targetUrl,
timestamp: Date.now(),
});
}
/**
* @param {string} message
* @returns {{statusCode: number, message: string}}
*/
function createBlockResponse(message) {
return {
statusCode: 403,
message,
};
}
/** @returns {RequestInterceptionHandler} */ /** @returns {RequestInterceptionHandler} */
function build() { function build() {
/** /**
@ -139,6 +171,7 @@ function createRequestContext(targetUrl, eventEmitter) {
return { return {
targetUrl, targetUrl,
blockMalware: blockMalwareSetup, blockMalware: blockMalwareSetup,
blockMinimumAgeRequest: blockMinimumAgeRequestSetup,
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func), modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
modifyBody: (func) => modifyBodyFuncs.push(func), modifyBody: (func) => modifyBodyFuncs.push(func),
build, build,

View file

@ -0,0 +1,33 @@
import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../config/settings.js";
import { getEquivalentPackageNames } from "../../scanning/packageNameVariants.js";
/**
* Checks if a package name matches an exclusion pattern.
* Supports trailing wildcard (*) for prefix matching.
* @param {string} packageName
* @param {string} pattern
* @returns {boolean}
*/
export function matchesExclusionPattern(packageName, pattern) {
if (pattern.endsWith("/*")) {
return packageName.startsWith(pattern.slice(0, -1));
}
return packageName === pattern;
}
/**
* @param {string | undefined} packageName
* @returns {boolean}
*/
export function isExcludedFromMinimumPackageAge(packageName) {
if (!packageName) {
return false;
}
const exclusions = getMinimumPackageAgeExclusions();
const candidateNames = getEquivalentPackageNames(packageName, getEcoSystem());
return exclusions.some((pattern) =>
candidateNames.some((name) => matchesExclusionPattern(name, pattern))
);
}

View file

@ -1,10 +1,7 @@
import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js"; import { getMinimumPackageAgeHours } from "../../../config/settings.js";
import { ui } from "../../../environment/userInteraction.js"; import { ui } from "../../../environment/userInteraction.js";
import { getHeaderValueAsString } from "../../http-utils.js"; import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js";
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
const state = {
hasSuppressedVersions: false,
};
/** /**
* @param {NodeJS.Dict<string | string[]>} headers * @param {NodeJS.Dict<string | string[]>} headers
@ -65,16 +62,6 @@ export function modifyNpmInfoResponse(body, headers) {
return body; return body;
} }
// Check if this package is excluded from minimum age filtering
const packageName = bodyJson.name;
const exclusions = getNpmMinimumPackageAgeExclusions();
if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) {
ui.writeVerbose(
`Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`
);
return body;
}
const cutOff = new Date( const cutOff = new Date(
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000 new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
); );
@ -92,15 +79,7 @@ export function modifyNpmInfoResponse(body, headers) {
const timestampValue = new Date(timestamp); const timestampValue = new Date(timestamp);
if (timestampValue > cutOff) { if (timestampValue > cutOff) {
deleteVersionFromJson(bodyJson, version); deleteVersionFromJson(bodyJson, version);
if (headers) { clearCachingHeaders(headers);
// When modifying the response, the etag and last-modified headers
// no longer match the content so they needs to be removed before sending the response.
delete headers["etag"];
delete headers["last-modified"];
// Removing the cache-control header will prevent the package manager from caching
// the modified response.
delete headers["cache-control"];
}
} }
} }
@ -124,7 +103,7 @@ export function modifyNpmInfoResponse(body, headers) {
* @param {string} version * @param {string} version
*/ */
function deleteVersionFromJson(json, version) { function deleteVersionFromJson(json, version) {
state.hasSuppressedVersions = true; recordSuppressedVersion();
const packageName = typeof json?.name === "string" ? json.name : "(unknown)"; const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
@ -182,22 +161,20 @@ function getMostRecentTag(tagList) {
} }
/** /**
* @returns {boolean} * @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {string | undefined}
*/ */
export function getHasSuppressedVersions() { export function getPackageNameFromMetadataResponse(body, headers) {
return state.hasSuppressedVersions; try {
} const contentType = getHeaderValueAsString(headers, "content-type");
if (!contentType?.toLowerCase().includes("application/json")) {
/** return undefined;
* Checks if a package name matches an exclusion pattern. }
* Supports trailing wildcard (*) for prefix matching.
* @param {string} packageName const bodyJson = JSON.parse(body.toString("utf8"));
* @param {string} pattern return typeof bodyJson.name === "string" ? bodyJson.name : undefined;
* @returns {boolean} } catch {
*/ return undefined;
function matchesExclusionPattern(packageName, pattern) {
if (pattern.endsWith("/*")) {
return packageName.startsWith(pattern.slice(0, -1));
} }
return packageName === pattern;
} }

View file

@ -5,11 +5,16 @@ import {
import { isMalwarePackage } from "../../../scanning/audit/index.js"; import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { interceptRequests } from "../interceptorBuilder.js"; import { interceptRequests } from "../interceptorBuilder.js";
import { import {
getPackageNameFromMetadataResponse,
isPackageInfoUrl, isPackageInfoUrl,
modifyNpmInfoRequestHeaders, modifyNpmInfoRequestHeaders,
modifyNpmInfoResponse, modifyNpmInfoResponse,
} from "./modifyNpmInfo.js"; } from "./modifyNpmInfo.js";
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js"; import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
import {
isExcludedFromMinimumPackageAge,
} from "../minimumPackageAgeExclusions.js";
const knownJsRegistries = [ const knownJsRegistries = [
"registry.npmjs.org", "registry.npmjs.org",
@ -43,14 +48,54 @@ function buildNpmInterceptor(registry) {
reqContext.targetUrl, reqContext.targetUrl,
registry registry
); );
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
if (await isMalwarePackage(packageName, version)) { if (await isMalwarePackage(packageName, version)) {
reqContext.blockMalware(packageName, version); reqContext.blockMalware(packageName, version);
return;
} }
if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) { if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) {
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders); reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
reqContext.modifyBody(modifyNpmInfoResponse); reqContext.modifyBody(modifyNpmInfoResponseUnlessExcluded);
return;
}
// For tarball requests the metadata check above is skipped, so we check the
// new packages list as a fallback (covers e.g. frozen-lockfile installs).
if (
minimumAgeChecksEnabled &&
packageName &&
version &&
!isExcludedFromMinimumPackageAge(packageName)
) {
const newPackagesDatabase = await openNewPackagesDatabase();
if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) {
reqContext.blockMinimumAgeRequest(
packageName,
version,
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
);
}
} }
}); });
} }
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {Buffer}
*/
function modifyNpmInfoResponseUnlessExcluded(body, headers) {
const metadataPackageName = getPackageNameFromMetadataResponse(body, headers);
if (
metadataPackageName &&
isExcludedFromMinimumPackageAge(metadataPackageName)
) {
return body;
}
return modifyNpmInfoResponse(body, headers);
}

View file

@ -5,13 +5,25 @@ describe("npmInterceptor minimum package age", async () => {
let minimumPackageAgeSettings = 48; let minimumPackageAgeSettings = 48;
let skipMinimumPackageAgeSetting = false; let skipMinimumPackageAgeSetting = false;
let minimumPackageAgeExclusionsSetting = []; let minimumPackageAgeExclusionsSetting = [];
let newlyReleasedPackages = new Set();
mock.module("../../../config/settings.js", { mock.module("../../../config/settings.js", {
namedExports: { namedExports: {
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
getMinimumPackageAgeHours: () => minimumPackageAgeSettings, getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
getNpmCustomRegistries: () => [], getNpmCustomRegistries: () => [],
getNpmMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting, getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
getEcoSystem: () => "js",
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (name, version) =>
newlyReleasedPackages.has(`${name}@${version}`),
}),
}, },
}); });
@ -359,6 +371,67 @@ describe("npmInterceptor minimum package age", async () => {
assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0"); assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0");
}); });
it("Should suppress too-young versions on metadata requests without directly blocking the request", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
const packageUrl = "https://registry.npmjs.org/lodash";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.equal(requestHandler.blockResponse, undefined);
assert.equal(requestHandler.modifiesResponse(), true);
});
it("Should directly block tarball requests when the new packages list marks them as too young", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const packageUrl =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.ok(requestHandler.blockResponse);
assert.equal(requestHandler.modifiesResponse(), false);
assert.equal(requestHandler.blockResponse.statusCode, 403);
assert.equal(
requestHandler.blockResponse.message,
"Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)"
);
});
it("Should not block tarball requests when skipMinimumPackageAge is enabled", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = true;
minimumPackageAgeExclusionsSetting = [];
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const packageUrl =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.equal(requestHandler.blockResponse, undefined);
assert.equal(requestHandler.modifiesResponse(), false);
});
it("Should not block tarball requests when the package is excluded from minimum age", async () => {
minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = ["lodash"];
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const packageUrl =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
const interceptor = npmInterceptorForUrl(packageUrl);
const requestHandler = await interceptor.handleRequest(packageUrl);
assert.equal(requestHandler.blockResponse, undefined);
assert.equal(requestHandler.modifiesResponse(), false);
});
it("Should not filter packages when package is in exclusion list", async () => { it("Should not filter packages when package is in exclusion list", async () => {
minimumPackageAgeSettings = 5; minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false; skipMinimumPackageAgeSetting = false;
@ -540,6 +613,7 @@ describe("npmInterceptor minimum package age", async () => {
minimumPackageAgeSettings = 5; minimumPackageAgeSettings = 5;
skipMinimumPackageAgeSetting = false; skipMinimumPackageAgeSetting = false;
minimumPackageAgeExclusionsSetting = []; // Reset to empty minimumPackageAgeExclusionsSetting = []; // Reset to empty
newlyReleasedPackages = new Set();
const packageUrl = "https://registry.npmjs.org/lodash"; const packageUrl = "https://registry.npmjs.org/lodash";

View file

@ -1,9 +1,11 @@
import { describe, it, mock } from "node:test"; import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
let lastPackage; let lastPackage;
let malwareResponse = false; let malwareResponse = false;
let customRegistries = []; let customRegistries = [];
let newlyReleasedPackages = new Set();
let skipMinimumPackageAgeSetting = false;
mock.module("../../../scanning/audit/index.js", { mock.module("../../../scanning/audit/index.js", {
namedExports: { namedExports: {
@ -26,14 +28,30 @@ mock.module("../../../config/settings.js", {
setEcoSystem: () => {}, setEcoSystem: () => {},
getMinimumPackageAgeHours: () => 24, getMinimumPackageAgeHours: () => 24,
getNpmCustomRegistries: () => customRegistries, getNpmCustomRegistries: () => customRegistries,
getNpmMinimumPackageAgeExclusions: () => [], getMinimumPackageAgeExclusions: () => [],
skipMinimumPackageAge: () => false, skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (name, version) =>
newlyReleasedPackages.has(`${name}@${version}`),
}),
}, },
}); });
describe("npmInterceptor", async () => { describe("npmInterceptor", async () => {
const { npmInterceptorForUrl } = await import("./npmInterceptor.js"); const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
beforeEach(() => {
lastPackage = undefined;
malwareResponse = false;
customRegistries = [];
newlyReleasedPackages = new Set();
skipMinimumPackageAgeSetting = false;
});
const parserCases = [ const parserCases = [
// Regular packages // Regular packages
{ {
@ -109,6 +127,10 @@ describe("npmInterceptor", async () => {
url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz", url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz",
expected: { packageName: "@babel/core", version: "7.21.4" }, expected: { packageName: "@babel/core", version: "7.21.4" },
}, },
{
url: "https://registry.yarnpkg.com/@music-i18n%2fverovio/-/verovio-1.4.1.tgz",
expected: { packageName: "@music-i18n/verovio", version: "1.4.1" },
},
// URL to get package info, not tarball // URL to get package info, not tarball
{ {
url: "https://registry.npmjs.org/lodash", url: "https://registry.npmjs.org/lodash",
@ -178,6 +200,36 @@ describe("npmInterceptor", async () => {
"Block response should have correct status message" "Block response should have correct status message"
); );
}); });
it("should block direct tarball downloads for newly released packages", async () => {
const url =
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123";
malwareResponse = false;
skipMinimumPackageAgeSetting = false;
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const interceptor = npmInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse);
assert.equal(result.blockResponse.statusCode, 403);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain direct download minimum package age (lodash@4.17.21)"
);
});
it("should not block direct tarball downloads when minimum age checks are skipped", async () => {
const url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz";
malwareResponse = false;
skipMinimumPackageAgeSetting = true;
newlyReleasedPackages = new Set(["lodash@4.17.21"]);
const interceptor = npmInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
});
}); });
describe("npmInterceptor with custom registries", async () => { describe("npmInterceptor with custom registries", async () => {

View file

@ -5,12 +5,29 @@
*/ */
export function parseNpmPackageUrl(url, registry) { export function parseNpmPackageUrl(url, registry) {
let packageName, version; let packageName, version;
if (!registry || !url.endsWith(".tgz")) { let parsedUrl;
try {
parsedUrl = new URL(url);
} catch {
return { packageName, version }; return { packageName, version };
} }
const registryIndex = url.indexOf(registry); const pathname = parsedUrl.pathname;
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
if (!registry || !pathname.endsWith(".tgz")) {
return { packageName, version };
}
const registryPrefix = `${registry}/`;
const urlAfterProtocol = `${parsedUrl.host}${pathname}`;
if (!urlAfterProtocol.startsWith(registryPrefix)) {
return { packageName, version };
}
const afterRegistry = decodeURIComponent(
urlAfterProtocol.substring(registryPrefix.length)
);
const separatorIndex = afterRegistry.indexOf("/-/"); const separatorIndex = afterRegistry.indexOf("/-/");
if (separatorIndex === -1) { if (separatorIndex === -1) {

View file

@ -0,0 +1,184 @@
import { ui } from "../../../environment/userInteraction.js";
import { clearCachingHeaders } from "../../http-utils.js";
import { normalizePipPackageName } from "../../../scanning/packageNameVariants.js";
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.js";
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
/**
* 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 =
/<a\b[^>]*href\s*=\s*(["'])([^"']+)\1[^>]*>[\s\S]*?<\/a>/gi;
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {Buffer}
*/
export function modifyPipInfoResponse(
body,
headers,
metadataUrl,
isNewlyReleasedPackage,
packageName
) {
try {
const contentType = getPipMetadataContentType(headers);
if (!contentType || body.byteLength === 0) {
return body;
}
if (
contentType.includes("html") ||
contentType.includes("application/vnd.pypi.simple.v1+html")
) {
return modifyHtmlSimpleResponse(
body,
headers,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
}
if (
contentType.includes("json") ||
contentType.includes("application/vnd.pypi.simple.v1+json")
) {
return modifyJsonResponse(
body,
headers,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
}
return body;
} catch (/** @type {any} */ err) {
ui.writeVerbose(
`Safe-chain: PyPI package metadata not in expected format - bypassing modification. Error: ${err.message}`
);
return body;
}
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {Buffer}
*/
function modifyHtmlSimpleResponse(
body,
headers,
metadataUrl,
isNewlyReleasedPackage,
packageName
) {
const html = body.toString("utf8");
let modified = false;
const rewriteHtmlAnchor = createHtmlAnchorRewriter(
metadataUrl,
isNewlyReleasedPackage,
packageName,
() => {
modified = true;
}
);
const updatedHtml = html.replace(HTML_ANCHOR_HREF_RE, rewriteHtmlAnchor);
if (!modified) return body;
const modifiedBuffer = Buffer.from(updatedHtml);
clearCachingHeaders(headers);
return modifiedBuffer;
}
/**
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @param {() => void} onModified
* @returns {(anchor: string, quote: string, href: string) => string}
*/
function createHtmlAnchorRewriter(
metadataUrl,
isNewlyReleasedPackage,
packageName,
onModified
) {
return (anchor, _quote, href) => {
const resolvedHref = new URL(href, metadataUrl).toString();
const { packageName: hrefPackageName, version } = parsePipPackageFromUrl(
resolvedHref,
new URL(resolvedHref).host
);
if (
hrefPackageName &&
normalizePipPackageName(hrefPackageName) ===
normalizePipPackageName(packageName) &&
version &&
isNewlyReleasedPackage(packageName, version)
) {
onModified();
logSuppressedVersion(packageName, version);
return "";
}
return anchor;
};
}
/**
* @param {Buffer} body
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {Buffer}
*/
function modifyJsonResponse(
body,
headers,
metadataUrl,
isNewlyReleasedPackage,
packageName
) {
const json = JSON.parse(body.toString("utf8"));
const modified = modifyPipJsonResponse(
json,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
if (!modified) return body;
const modifiedBuffer = Buffer.from(JSON.stringify(json));
clearCachingHeaders(headers);
return modifiedBuffer;
}

View file

@ -0,0 +1,302 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("modifyPipInfo", async () => {
mock.module("../../../config/settings.js", {
namedExports: {
getMinimumPackageAgeHours: () => 48,
ECOSYSTEM_PY: "py",
},
});
mock.module("../../../environment/userInteraction.js", {
namedExports: {
ui: {
writeVerbose: () => {},
},
},
});
const {
modifyPipInfoResponse,
} = await import("./modifyPipInfo.js");
it("removes too-young files from simple HTML metadata", () => {
const headers = {
"content-type": "application/vnd.pypi.simple.v1+html",
etag: "abc",
"cache-control": "public",
"content-length": "999",
"transfer-encoding": "chunked",
};
const body = Buffer.from(`
<!doctype html>
<html>
<body>
<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz">requests-1.0.0.tar.gz</a>
<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-2.0.0.tar.gz">requests-2.0.0.tar.gz</a>
</body>
</html>
`);
const modified = modifyPipInfoResponse(
body,
headers,
"https://pypi.org/simple/requests/",
(_packageName, version) => version === "2.0.0",
"requests"
).toString("utf8");
assert.ok(modified.includes("requests-1.0.0.tar.gz"));
assert.ok(!modified.includes("requests-2.0.0.tar.gz"));
assert.equal(headers.etag, undefined);
assert.equal(headers["cache-control"], undefined);
assert.equal(headers["content-length"], undefined);
assert.equal(headers["transfer-encoding"], "chunked");
});
it("leaves mixed-case transport headers untouched for MITM layer to normalize", () => {
const headers = {
"content-type": "application/json",
ETag: "abc",
"Content-Length": "999",
"Last-Modified": "yesterday",
"Cache-Control": "public, max-age=60",
"Transfer-Encoding": "chunked",
};
const body = Buffer.from(
JSON.stringify({
info: { version: "2.0.0" },
releases: {
"1.0.0": [{ filename: "requests-1.0.0.tar.gz" }],
"2.0.0": [{ filename: "requests-2.0.0.tar.gz" }],
},
})
);
modifyPipInfoResponse(
body,
headers,
"https://pypi.org/pypi/requests/json",
(_packageName, version) => version === "2.0.0",
"requests"
);
assert.equal(headers.ETag, "abc");
assert.equal(headers["Last-Modified"], "yesterday");
assert.equal(headers["Cache-Control"], "public, max-age=60");
assert.equal(headers["Transfer-Encoding"], "chunked");
assert.equal(headers["Content-Length"], "999");
assert.equal(headers["content-length"], undefined);
});
it("returns body unchanged when no HTML versions are suppressed", () => {
const headers = {
"content-type": "application/vnd.pypi.simple.v1+html",
etag: "abc",
};
const body = Buffer.from(
`<a href="https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz">requests-1.0.0.tar.gz</a>`
);
const result = modifyPipInfoResponse(
body,
headers,
"https://pypi.org/simple/requests/",
() => false,
"requests"
);
assert.equal(result, body); // same Buffer reference — no copy made
assert.equal(headers.etag, "abc"); // headers untouched
});
it("matches HTML anchor hrefs using normalised package name (underscore vs hyphen)", () => {
const headers = { "content-type": "application/vnd.pypi.simple.v1+html" };
const body = Buffer.from(
`<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz">foo_bar-2.0.0.tar.gz</a>` +
`<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>`
);
const modified = modifyPipInfoResponse(
body,
headers,
"https://pypi.org/simple/foo-bar/",
(_packageName, version) => version === "2.0.0",
"foo-bar" // hyphenated name, hrefs use underscore
).toString("utf8");
assert.ok(!modified.includes("foo_bar-2.0.0.tar.gz"));
assert.ok(modified.includes("foo_bar-1.0.0.tar.gz"));
});
it("matches anchor href regex with single quotes and extra attributes", () => {
const headers = { "content-type": "application/vnd.pypi.simple.v1+html" };
const body = Buffer.from(`
<a
data-requires-python="&gt;=3.9"
class="pkg"
href='https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz'
>
foo_bar-2.0.0.tar.gz
</a>
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>
`);
const modified = modifyPipInfoResponse(
body,
headers,
"https://pypi.org/simple/foo-bar/",
(_packageName, version) => version === "2.0.0",
"foo-bar"
).toString("utf8");
assert.ok(!modified.includes("foo_bar-2.0.0.tar.gz"));
assert.ok(modified.includes("foo_bar-1.0.0.tar.gz"));
});
it("removes too-young files from simple JSON metadata", () => {
const headers = {
"content-type": "application/vnd.pypi.simple.v1+json",
};
const body = Buffer.from(
JSON.stringify({
name: "requests",
files: [
{
filename: "requests-1.0.0.tar.gz",
url: "https://files.pythonhosted.org/packages/source/r/requests/requests-1.0.0.tar.gz",
},
{
filename: "requests-2.0.0.tar.gz",
url: "https://files.pythonhosted.org/packages/source/r/requests/requests-2.0.0.tar.gz",
},
],
})
);
const modified = JSON.parse(
modifyPipInfoResponse(
body,
headers,
"https://pypi.org/simple/requests/",
(_packageName, version) => version === "2.0.0",
"requests"
).toString("utf8")
);
assert.equal(modified.files.length, 1);
assert.equal(modified.files[0].filename, "requests-1.0.0.tar.gz");
});
it("filters simple JSON metadata entries that have only filename (no url)", () => {
const headers = { "content-type": "application/vnd.pypi.simple.v1+json" };
const body = Buffer.from(
JSON.stringify({
name: "requests",
files: [
{ filename: "requests-1.0.0.tar.gz" },
{ filename: "requests-2.0.0.tar.gz" },
],
})
);
const modified = JSON.parse(
modifyPipInfoResponse(
body,
headers,
"https://pypi.org/simple/requests/",
(_packageName, version) => version === "2.0.0",
"requests"
).toString("utf8")
);
assert.equal(modified.files.length, 1);
assert.equal(modified.files[0].filename, "requests-1.0.0.tar.gz");
});
it("recalculates JSON API info.version after removing too-young releases", () => {
const headers = {
"content-type": "application/json",
};
const body = Buffer.from(
JSON.stringify({
info: { version: "2.0.0" },
releases: {
"1.0.0": [
{
filename: "requests-1.0.0.tar.gz",
upload_time_iso_8601: "2024-01-01T00:00:00.000Z",
},
],
"2.0.0": [
{
filename: "requests-2.0.0.tar.gz",
upload_time_iso_8601: "2024-01-02T00:00:00.000Z",
},
],
"3.0.0rc1": [
{
filename: "requests-3.0.0rc1.tar.gz",
upload_time_iso_8601: "2024-01-03T00:00:00.000Z",
},
],
},
urls: [
{ filename: "requests-2.0.0.tar.gz" },
],
})
);
const modified = JSON.parse(
modifyPipInfoResponse(
body,
headers,
"https://pypi.org/pypi/requests/json",
(_packageName, version) =>
version === "2.0.0" || version === "3.0.0rc1",
"requests"
).toString("utf8")
);
assert.deepEqual(Object.keys(modified.releases), ["1.0.0"]);
assert.equal(modified.info.version, "1.0.0");
assert.equal(modified.urls.length, 0);
});
it("falls back to latest pre-release when all stable versions are removed", () => {
const headers = { "content-type": "application/json" };
const body = Buffer.from(
JSON.stringify({
info: { version: "2.0.0rc2" },
releases: {
"1.0.0rc1": [{ filename: "requests-1.0.0rc1.tar.gz" }],
"2.0.0rc2": [{ filename: "requests-2.0.0rc2.tar.gz" }],
},
urls: [],
})
);
const modified = JSON.parse(
modifyPipInfoResponse(
body,
headers,
"https://pypi.org/pypi/requests/json",
(_packageName, version) => version === "2.0.0rc2",
"requests"
).toString("utf8")
);
assert.deepEqual(Object.keys(modified.releases), ["1.0.0rc1"]);
assert.equal(modified.info.version, "1.0.0rc1");
});
});

View file

@ -0,0 +1,176 @@
import {
calculateLatestVersion,
getAvailableVersionsFromJson,
getPackageVersionFromMetadataFile,
} from "./pipMetadataVersionUtils.js";
import { logSuppressedVersion } from "./pipMetadataResponseUtils.js";
/**
* @param {any} json
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {boolean}
*/
export function modifyPipJsonResponse(
json,
metadataUrl,
isNewlyReleasedPackage,
packageName
) {
const filesModified = filterJsonMetadataFiles(
json,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
const releasesModified = removeJsonMetadataReleases(
json,
isNewlyReleasedPackage,
packageName
);
const urlsModified = filterJsonMetadataUrls(
json,
metadataUrl,
isNewlyReleasedPackage,
packageName
);
const versionModified = updateJsonInfoVersion(json, metadataUrl);
return filesModified || releasesModified || urlsModified || versionModified;
}
/**
* @param {any} json
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {boolean}
*/
function filterJsonMetadataFiles(
json,
metadataUrl,
isNewlyReleasedPackage,
packageName
) {
if (!Array.isArray(json.files)) {
return false;
}
let modified = false;
const loggedVersions = new Set();
json.files = json.files.filter((/** @type {any} */ file) => {
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
if (version && isNewlyReleasedPackage(packageName, version)) {
modified = true;
if (!loggedVersions.has(version)) {
logSuppressedVersion(packageName, version);
loggedVersions.add(version);
}
return false;
}
return true;
});
return modified;
}
/**
* @param {any} json
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {boolean}
*/
function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
if (!json.releases || typeof json.releases !== "object") {
return false;
}
let modified = false;
for (const [version, files] of Object.entries(json.releases)) {
if (
Array.isArray(/** @type {unknown[]} */ (files)) &&
isNewlyReleasedPackage(packageName, version)
) {
delete json.releases[version];
modified = true;
logSuppressedVersion(packageName, version);
}
}
return modified;
}
/**
* @param {any} json
* @param {string} metadataUrl
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
* @param {string} packageName
* @returns {boolean}
*/
function filterJsonMetadataUrls(
json,
metadataUrl,
isNewlyReleasedPackage,
packageName
) {
if (!Array.isArray(json.urls)) {
return false;
}
let modified = false;
const loggedVersions = new Set();
json.urls = json.urls.filter((/** @type {any} */ file) => {
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
if (version && isNewlyReleasedPackage(packageName, version)) {
modified = true;
if (!loggedVersions.has(version)) {
logSuppressedVersion(packageName, version);
loggedVersions.add(version);
}
return false;
}
return true;
});
return modified;
}
/**
* @param {any} json
* @param {string} metadataUrl
* @returns {boolean}
*/
function updateJsonInfoVersion(json, metadataUrl) {
if (!json.info || typeof json.info !== "object") {
return false;
}
const replacementVersion = computeReplacementVersion(json, metadataUrl);
if (
typeof json.info.version !== "string" ||
!replacementVersion ||
json.info.version === replacementVersion
) {
return false;
}
json.info.version = replacementVersion;
return true;
}
/**
* @param {any} json
* @param {string} metadataUrl
* @returns {string | undefined}
*/
function computeReplacementVersion(json, metadataUrl) {
const candidateVersions = getAvailableVersionsFromJson(json, metadataUrl);
return calculateLatestVersion(candidateVersions);
}

View file

@ -0,0 +1,162 @@
/**
* Parses a PyPI metadata URL and returns the package name and API type.
*
* @example
* parsePipMetadataUrl("https://pypi.org/simple/requests/")
* // => { packageName: "requests", type: "simple" }
*
* parsePipMetadataUrl("https://pypi.org/pypi/requests/json")
* // => { packageName: "requests", type: "json" }
*
* parsePipMetadataUrl("https://pypi.org/pypi/requests/2.28.1/json")
* // => { packageName: "requests", type: "json" }
*
* parsePipMetadataUrl("https://files.pythonhosted.org/packages/requests-2.28.1.tar.gz")
* // => { packageName: undefined, type: undefined }
*
* @param {string} url
* @returns {{ packageName: string | undefined, type: "simple" | "json" | undefined }}
*/
export function parsePipMetadataUrl(url) {
if (typeof url !== "string") {
return { packageName: undefined, type: undefined };
}
let urlObj;
try {
urlObj = new URL(url);
} catch {
return { packageName: undefined, type: undefined };
}
const pathSegments = urlObj.pathname.split("/").filter(Boolean);
if (pathSegments[0] === "simple" && pathSegments[1]) {
return {
packageName: decodeURIComponent(pathSegments[1]),
type: "simple",
};
}
if (
pathSegments[0] === "pypi" &&
pathSegments[pathSegments.length - 1] === "json" &&
pathSegments[1]
) {
return {
packageName: decodeURIComponent(pathSegments[1]),
type: "json",
};
}
return { packageName: undefined, type: undefined };
}
/**
* @param {string} url
* @returns {boolean}
*/
export function isPipPackageInfoUrl(url) {
return !!parsePipMetadataUrl(url).packageName;
}
/**
* Parse Python package artifact URLs from PyPI-style registries.
* Examples:
* - Wheel: https://files.pythonhosted.org/packages/.../requests-2.28.1-py3-none-any.whl
* - Wheel metadata: https://files.pythonhosted.org/packages/.../requests-2.28.1-py3-none-any.whl.metadata
* - Sdist: https://files.pythonhosted.org/packages/.../requests-2.28.1.tar.gz
*
* @param {string} url
* @param {string} registry
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
export function parsePipPackageFromUrl(url, registry) {
if (!registry || typeof url !== "string") {
return { packageName: undefined, version: undefined };
}
let urlObj;
try {
urlObj = new URL(url);
} catch {
return { packageName: undefined, version: undefined };
}
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
if (!lastSegment) {
return { packageName: undefined, version: undefined };
}
const filename = decodeURIComponent(lastSegment);
const wheelExtRe = /\.whl(?:\.metadata)?$/;
if (wheelExtRe.test(filename)) {
return parseWheelFilename(filename, wheelExtRe);
}
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
if (!sdistExtWithMetadataRe.test(filename)) {
return { packageName: undefined, version: undefined };
}
return parseSdistFilename(filename, sdistExtWithMetadataRe);
}
/**
* Parse wheel filenames and Poetry preflight metadata.
* Examples:
* - foo_bar-2.0.0-py3-none-any.whl
* - foo_bar-2.0.0-py3-none-any.whl.metadata
*
* @param {string} filename
* @param {RegExp} wheelExtRe
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
function parseWheelFilename(filename, wheelExtRe) {
const base = filename.replace(wheelExtRe, "");
const firstDash = base.indexOf("-");
if (firstDash <= 0) {
return { packageName: undefined, version: undefined };
}
const packageName = base.slice(0, firstDash);
const rest = base.slice(firstDash + 1);
const secondDash = rest.indexOf("-");
const version = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
// "latest" is a resolver-style token, not an actual published artifact version.
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
/**
* Parse source distribution filenames, with optional metadata suffix.
* Examples:
* - requests-2.28.1.tar.gz
* - requests-2.28.1.zip
* - requests-2.28.1.tar.gz.metadata
*
* @param {string} filename
* @param {RegExp} sdistExtWithMetadataRe
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
function parseSdistFilename(filename, sdistExtWithMetadataRe) {
const base = filename.replace(sdistExtWithMetadataRe, "");
const lastDash = base.lastIndexOf("-");
if (lastDash <= 0 || lastDash >= base.length - 1) {
return { packageName: undefined, version: undefined };
}
const packageName = base.slice(0, lastDash);
const version = base.slice(lastDash + 1);
// "latest" is a resolver-style token, not an actual published artifact version.
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}

View file

@ -0,0 +1,100 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import {
isPipPackageInfoUrl,
parsePipMetadataUrl,
parsePipPackageFromUrl,
} from "./parsePipPackageUrl.js";
describe("parsePipPackageUrl", () => {
it("parses simple metadata URLs", () => {
assert.deepEqual(parsePipMetadataUrl("https://pypi.org/simple/requests/"), {
packageName: "requests",
type: "simple",
});
});
it("parses json metadata URLs", () => {
assert.deepEqual(parsePipMetadataUrl("https://pypi.org/pypi/requests/json"), {
packageName: "requests",
type: "json",
});
});
it("parses per-version json metadata URLs", () => {
assert.deepEqual(
parsePipMetadataUrl("https://pypi.org/pypi/requests/2.28.1/json"),
{ packageName: "requests", type: "json" }
);
});
it("decodes encoded metadata package names", () => {
assert.deepEqual(
parsePipMetadataUrl("https://pypi.org/simple/foo-bar%5Fbaz/"),
{
packageName: "foo-bar_baz",
type: "simple",
}
);
});
it("returns undefined for unrecognized metadata paths", () => {
assert.deepEqual(
parsePipMetadataUrl("https://pypi.org/unknown/requests/"),
{
packageName: undefined,
type: undefined,
}
);
});
it("returns undefined for invalid metadata URLs", () => {
assert.deepEqual(parsePipMetadataUrl("not a url"), {
packageName: undefined,
type: undefined,
});
});
it("recognizes package info URLs", () => {
assert.equal(
isPipPackageInfoUrl("https://pypi.org/simple/requests/"),
true
);
});
it("does not treat artifact URLs as package info URLs", () => {
assert.equal(
isPipPackageInfoUrl(
"https://files.pythonhosted.org/packages/source/r/requests/requests-2.28.1.tar.gz"
),
false
);
});
it("parses wheel artifact URLs", () => {
assert.deepEqual(
parsePipPackageFromUrl(
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
"files.pythonhosted.org"
),
{ packageName: "foo_bar", version: "2.0.0" }
);
});
it("parses sdist artifact URLs", () => {
assert.deepEqual(
parsePipPackageFromUrl(
"https://files.pythonhosted.org/packages/source/r/requests/requests-2.28.1.tar.gz",
"files.pythonhosted.org"
),
{ packageName: "requests", version: "2.28.1" }
);
});
it("returns undefined for non-artifact URLs", () => {
assert.deepEqual(
parsePipPackageFromUrl("https://pypi.org/simple/requests/", "pypi.org"),
{ packageName: undefined, version: undefined }
);
});
});

View file

@ -2,20 +2,36 @@ import { describe, it, mock } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
describe("pipInterceptor custom registries", async () => { describe("pipInterceptor custom registries", async () => {
let lastPackage; let scannedPackages;
let malwareResponse = false; let malwareResponse = false;
let customRegistries = []; let customRegistries = [];
mock.module("../../config/settings.js", { mock.module("../../../config/settings.js", {
namedExports: { namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getLoggingLevel: () => "silent",
getMinimumPackageAgeHours: () => 48,
getMinimumPackageAgeExclusions: () => [],
getPipCustomRegistries: () => customRegistries, getPipCustomRegistries: () => customRegistries,
LOGGING_SILENT: "silent",
LOGGING_VERBOSE: "verbose",
skipMinimumPackageAge: () => false,
}, },
}); });
mock.module("../../scanning/audit/index.js", { mock.module("../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: () => false,
}),
},
});
mock.module("../../../scanning/audit/index.js", {
namedExports: { namedExports: {
isMalwarePackage: async (packageName, version) => { isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version }; scannedPackages.push({ packageName, version });
return malwareResponse; return malwareResponse;
}, },
}, },
@ -30,42 +46,45 @@ describe("pipInterceptor custom registries", async () => {
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.ok( assert.ok(interceptor);
interceptor,
"Interceptor should be created for custom registry"
);
}); });
it("should parse package from custom registry URL", async () => { it("should parse package from custom registry URL", async () => {
scannedPackages = [];
customRegistries = ["my-custom-registry.example.com"]; customRegistries = ["my-custom-registry.example.com"];
const url = const url =
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; "https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created"); assert.ok(interceptor);
await interceptor.handleRequest(url); await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, { assert.ok(
packageName: "foobar", scannedPackages.some(
version: "1.2.3", ({ packageName, version }) =>
}); packageName === "foobar" && version === "1.2.3"
)
);
}); });
it("should parse wheel package from custom registry URL", async () => { it("should parse wheel package from custom registry URL", async () => {
scannedPackages = [];
customRegistries = ["private-pypi.internal.com"]; customRegistries = ["private-pypi.internal.com"];
const url = const url =
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl"; "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl";
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created"); assert.ok(interceptor);
await interceptor.handleRequest(url); await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, { assert.ok(
packageName: "foo-bar", scannedPackages.some(
version: "2.0.0", ({ packageName, version }) =>
}); packageName === "foo-bar" && version === "2.0.0"
)
);
}); });
it("should handle multiple custom registries", async () => { it("should handle multiple custom registries", async () => {
@ -82,14 +101,12 @@ describe("pipInterceptor custom registries", async () => {
const interceptor1 = pipInterceptorForUrl(url1); const interceptor1 = pipInterceptorForUrl(url1);
const interceptor2 = pipInterceptorForUrl(url2); const interceptor2 = pipInterceptorForUrl(url2);
assert.ok(interceptor1, "Interceptor should be created for first registry"); assert.ok(interceptor1);
assert.ok( assert.ok(interceptor2);
interceptor2,
"Interceptor should be created for second registry"
);
}); });
it("should block malicious package from custom registry", async () => { it("should block malicious package from custom registry", async () => {
scannedPackages = [];
customRegistries = ["my-custom-registry.example.com"]; customRegistries = ["my-custom-registry.example.com"];
malwareResponse = true; malwareResponse = true;
@ -97,26 +114,19 @@ describe("pipInterceptor custom registries", async () => {
"https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz"; "https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz";
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created"); assert.ok(interceptor);
const result = await interceptor.handleRequest(url); const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse, "Should contain a blockResponse"); assert.ok(result.blockResponse);
assert.equal( assert.equal(result.blockResponse.statusCode, 403);
result.blockResponse.statusCode, assert.equal(result.blockResponse.message, "Forbidden - blocked by safe-chain");
403,
"Block response should have status code 403"
);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain",
"Block response should have correct status message"
);
malwareResponse = false; malwareResponse = false;
}); });
it("should still work with known registries when custom registries are set", async () => { it("should still work with known registries when custom registries are set", async () => {
scannedPackages = [];
customRegistries = ["my-custom-registry.example.com"]; customRegistries = ["my-custom-registry.example.com"];
const url = const url =
@ -124,17 +134,16 @@ describe("pipInterceptor custom registries", async () => {
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.ok( assert.ok(interceptor);
interceptor,
"Interceptor should be created for known registry even with custom registries set"
);
await interceptor.handleRequest(url); await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, { assert.ok(
packageName: "foobar", scannedPackages.some(
version: "1.2.3", ({ packageName, version }) =>
}); packageName === "foobar" && version === "1.2.3"
)
);
}); });
it("should not create interceptor for unknown registry when custom registries are set", () => { it("should not create interceptor for unknown registry when custom registries are set", () => {
@ -143,11 +152,7 @@ describe("pipInterceptor custom registries", async () => {
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.equal( assert.equal(interceptor, undefined);
interceptor,
undefined,
"Interceptor should be undefined for unknown registry"
);
}); });
it("should handle empty custom registries array", () => { it("should handle empty custom registries array", () => {
@ -157,43 +162,44 @@ describe("pipInterceptor custom registries", async () => {
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.equal( assert.equal(interceptor, undefined);
interceptor,
undefined,
"Interceptor should be undefined when no custom registries are configured"
);
}); });
it("should parse .whl.metadata from custom registry", async () => { it("should parse .whl.metadata from custom registry", async () => {
scannedPackages = [];
customRegistries = ["private-pypi.internal.com"]; customRegistries = ["private-pypi.internal.com"];
const url = const url =
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata"; "https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata";
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created"); assert.ok(interceptor);
await interceptor.handleRequest(url); await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, { assert.ok(
packageName: "foo-bar", scannedPackages.some(
version: "2.0.0", ({ packageName, version }) =>
}); packageName === "foo-bar" && version === "2.0.0"
)
);
}); });
it("should parse .tar.gz.metadata from custom registry", async () => { it("should parse .tar.gz.metadata from custom registry", async () => {
scannedPackages = [];
customRegistries = ["private-pypi.internal.com"]; customRegistries = ["private-pypi.internal.com"];
const url = const url =
"https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata"; "https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata";
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.ok(interceptor, "Interceptor should be created"); assert.ok(interceptor);
await interceptor.handleRequest(url); await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, { assert.ok(
packageName: "foo-bar", scannedPackages.some(
version: "2.0.0", ({ packageName, version }) =>
}); packageName === "foo-bar" && version === "2.0.0"
)
);
}); });
}); });

View file

@ -0,0 +1,124 @@
import {
ECOSYSTEM_PY,
getPipCustomRegistries,
skipMinimumPackageAge,
} from "../../../config/settings.js";
import { isMalwarePackage } from "../../../scanning/audit/index.js";
import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants.js";
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
import { interceptRequests } from "../interceptorBuilder.js";
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
import {
modifyPipInfoRequestHeaders,
modifyPipInfoResponse,
parsePipMetadataUrl,
} from "./modifyPipInfo.js";
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
const knownPipRegistries = [
"files.pythonhosted.org",
"pypi.org",
"pypi.python.org",
"pythonhosted.org",
];
/**
* @param {string} url
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
*/
export function pipInterceptorForUrl(url) {
const customRegistries = getPipCustomRegistries();
const registries = [...knownPipRegistries, ...customRegistries];
const registry = registries.find((reg) => url.includes(reg));
if (registry) {
return buildPipInterceptor(registry);
}
return undefined;
}
/**
* @param {string} registry
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
*/
function buildPipInterceptor(registry) {
return interceptRequests(createPipRequestHandler(registry));
}
/**
* @param {string} registry
* @returns {(reqContext: import("../interceptorBuilder.js").RequestInterceptionContext) => Promise<void>}
*/
function createPipRequestHandler(registry) {
return async (reqContext) => {
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
const metadataInfo = parsePipMetadataUrl(reqContext.targetUrl);
const metadataPackageName = metadataInfo.packageName;
if (
minimumAgeChecksEnabled &&
metadataPackageName &&
!isExcludedFromMinimumPackageAge(metadataPackageName)
) {
const newPackagesDatabase = await openNewPackagesDatabase();
reqContext.modifyRequestHeaders(modifyPipInfoRequestHeaders);
reqContext.modifyBody((body, headers) =>
modifyPipInfoResponse(
body,
headers,
reqContext.targetUrl,
newPackagesDatabase.isNewlyReleasedPackage,
metadataPackageName
)
);
return;
}
const { packageName, version } = parsePipPackageFromUrl(
reqContext.targetUrl,
registry
);
if (!packageName) {
return;
}
const equivalentPackageNames = getEquivalentPackageNames(
packageName,
ECOSYSTEM_PY
);
let isMalicious = false;
for (const equivalentPackageName of equivalentPackageNames) {
if (await isMalwarePackage(equivalentPackageName, version)) {
isMalicious = true;
break;
}
}
if (isMalicious) {
reqContext.blockMalware(packageName, version);
return;
}
if (
version &&
minimumAgeChecksEnabled &&
!isExcludedFromMinimumPackageAge(packageName)
) {
const newPackagesDatabase = await openNewPackagesDatabase();
const isNewlyReleased = newPackagesDatabase.isNewlyReleasedPackage(
packageName,
version
);
if (isNewlyReleased) {
reqContext.blockMinimumAgeRequest(
packageName,
version,
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
);
}
}
};
}

View file

@ -0,0 +1,168 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("pipInterceptor minimum package age", async () => {
let skipMinimumPackageAgeSetting = false;
let newlyReleasedPackageResponse = false;
let minimumPackageAgeExclusionsSetting = [];
mock.module("../../../scanning/audit/index.js", {
namedExports: {
isMalwarePackage: async () => false,
},
});
mock.module("../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: (packageName, version) => {
return newlyReleasedPackageResponse &&
(packageName === "foo-bar" ||
packageName === "foo_bar" ||
packageName === "foo.bar") &&
version === "2.0.0";
},
}),
},
});
mock.module("../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getLoggingLevel: () => "silent",
getMinimumPackageAgeHours: () => 48,
getMinimumPackageAgeExclusions: () => minimumPackageAgeExclusionsSetting,
getPipCustomRegistries: () => [],
LOGGING_SILENT: "silent",
LOGGING_VERBOSE: "verbose",
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
},
});
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
it("should block newly released package downloads", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
newlyReleasedPackageResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse);
assert.equal(result.blockResponse.statusCode, 403);
assert.equal(
result.blockResponse.message,
"Forbidden - blocked by safe-chain direct download minimum package age (foo_bar@2.0.0)"
);
newlyReleasedPackageResponse = false;
});
it("should modify simple metadata responses to suppress too-young versions", async () => {
const url = "https://pypi.org/simple/foo-bar/";
newlyReleasedPackageResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.modifiesResponse(), true);
const modifiedBody = result.modifyBody(
Buffer.from(`
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-1.0.0.tar.gz">foo_bar-1.0.0.tar.gz</a>
<a href="https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz">foo_bar-2.0.0.tar.gz</a>
`),
{
"content-type": "application/vnd.pypi.simple.v1+html",
}
).toString("utf8");
assert.ok(modifiedBody.includes("foo_bar-1.0.0.tar.gz"));
assert.ok(!modifiedBody.includes("foo_bar-2.0.0.tar.gz"));
newlyReleasedPackageResponse = false;
});
it("should not block newly released package downloads when skipMinimumPackageAge is enabled", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
newlyReleasedPackageResponse = true;
skipMinimumPackageAgeSetting = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
skipMinimumPackageAgeSetting = false;
newlyReleasedPackageResponse = false;
});
it("should not block newly released package downloads when the package is excluded", async () => {
const url =
"https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl";
newlyReleasedPackageResponse = true;
minimumPackageAgeExclusionsSetting = ["foo-bar"];
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
minimumPackageAgeExclusionsSetting = [];
newlyReleasedPackageResponse = false;
});
it("should not modify metadata responses when the package is excluded", async () => {
const url = "https://pypi.org/simple/foo-bar/";
newlyReleasedPackageResponse = true;
minimumPackageAgeExclusionsSetting = ["foo-bar"];
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.modifiesResponse(), false);
minimumPackageAgeExclusionsSetting = [];
newlyReleasedPackageResponse = false;
});
it("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";
newlyReleasedPackageResponse = true;
minimumPackageAgeExclusionsSetting = ["foo-bar"];
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
assert.equal(result.blockResponse, undefined);
minimumPackageAgeExclusionsSetting = [];
newlyReleasedPackageResponse = false;
});
});

View file

@ -2,22 +2,43 @@ import { describe, it, mock } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
describe("pipInterceptor", async () => { describe("pipInterceptor", async () => {
let lastPackage; let scannedPackages;
let malwareResponse = false; let malwareResponse = false;
mock.module("../../scanning/audit/index.js", { mock.module("../../../scanning/audit/index.js", {
namedExports: { namedExports: {
isMalwarePackage: async (packageName, version) => { isMalwarePackage: async (packageName, version) => {
lastPackage = { packageName, version }; scannedPackages.push({ packageName, version });
return malwareResponse; return malwareResponse;
}, },
}, },
}); });
mock.module("../../../scanning/newPackagesListCache.js", {
namedExports: {
openNewPackagesDatabase: async () => ({
isNewlyReleasedPackage: () => false,
}),
},
});
mock.module("../../../config/settings.js", {
namedExports: {
ECOSYSTEM_PY: "py",
getEcoSystem: () => "py",
getLoggingLevel: () => "silent",
getMinimumPackageAgeHours: () => 48,
getMinimumPackageAgeExclusions: () => [],
getPipCustomRegistries: () => [],
LOGGING_SILENT: "silent",
LOGGING_VERBOSE: "verbose",
skipMinimumPackageAge: () => false,
},
});
const { pipInterceptorForUrl } = await import("./pipInterceptor.js"); const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
const parserCases = [ const parserCases = [
// Valid pip URLs
{ {
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz", url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz",
expected: { packageName: "foobar", version: "1.2.3" }, expected: { packageName: "foobar", version: "1.2.3" },
@ -35,7 +56,6 @@ describe("pipInterceptor", async () => {
expected: { packageName: "foo-bar", version: "2.0.0" }, expected: { packageName: "foo-bar", version: "2.0.0" },
}, },
{ {
// Poetry preflight metadata alongside wheel (.whl.metadata)
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata", url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata",
expected: { packageName: "foo-bar", version: "2.0.0" }, expected: { packageName: "foo-bar", version: "2.0.0" },
}, },
@ -52,7 +72,6 @@ describe("pipInterceptor", async () => {
expected: { packageName: "foo-bar", version: "2.0.0b1" }, expected: { packageName: "foo-bar", version: "2.0.0b1" },
}, },
{ {
// sdist with metadata sidecar (.tar.gz.metadata)
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata", url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata",
expected: { packageName: "foo-bar", version: "2.0.0" }, expected: { packageName: "foo-bar", version: "2.0.0" },
}, },
@ -76,7 +95,6 @@ describe("pipInterceptor", async () => {
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl", url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl",
expected: { packageName: "foo-bar", version: "2.0.0" }, expected: { packageName: "foo-bar", version: "2.0.0" },
}, },
// Invalid pip URLs
{ {
url: "https://pypi.org/simple/", url: "https://pypi.org/simple/",
expected: { packageName: undefined, version: undefined }, expected: { packageName: undefined, version: undefined },
@ -97,49 +115,49 @@ describe("pipInterceptor", async () => {
parserCases.forEach(({ url, expected }, index) => { parserCases.forEach(({ url, expected }, index) => {
it(`should parse URL ${index + 1}: ${url}`, async () => { it(`should parse URL ${index + 1}: ${url}`, async () => {
scannedPackages = [];
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.ok( assert.ok(interceptor, "Interceptor should be created for known pip registry");
interceptor,
"Interceptor should be created for known npm registry"
);
await interceptor.handleRequest(url); await interceptor.handleRequest(url);
assert.deepEqual(lastPackage, expected); if (expected.packageName === undefined) {
assert.deepEqual(scannedPackages, []);
return;
}
assert.ok(
scannedPackages.some(
({ packageName, version }) =>
packageName === expected.packageName &&
version === expected.version
)
);
}); });
}); });
it("should not create interceptor for unknown registry", () => { it("should not create interceptor for unknown registry", () => {
const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz"; const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
assert.equal(interceptor, undefined);
assert.equal(
interceptor,
undefined,
"Interceptor should be undefined for unknown registry"
);
}); });
it("should block malicious package", async () => { it("should block malicious package", async () => {
scannedPackages = [];
const url = const url =
"https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz"; "https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz";
malwareResponse = true; malwareResponse = true;
const interceptor = pipInterceptorForUrl(url); const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url); const result = await interceptor.handleRequest(url);
assert.ok(result.blockResponse, "Should contain a blockResponse"); assert.ok(result.blockResponse);
assert.equal( assert.equal(result.blockResponse.statusCode, 403);
result.blockResponse.statusCode,
403,
"Block response should have status code 403"
);
assert.equal( assert.equal(
result.blockResponse.message, result.blockResponse.message,
"Forbidden - blocked by safe-chain", "Forbidden - blocked by safe-chain"
"Block response should have correct status message"
); );
malwareResponse = false;
}); });
}); });

View file

@ -0,0 +1,27 @@
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
import { ui } from "../../../environment/userInteraction.js";
import { getHeaderValueAsString } from "../../http-utils.js";
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
/**
* @param {NodeJS.Dict<string | string[]> | undefined} headers
* @returns {string | undefined}
*/
export function getPipMetadataContentType(headers) {
return getHeaderValueAsString(headers, "content-type")
?.toLowerCase()
.split(";")[0]
.trim();
}
/**
* @param {string} packageName
* @param {string} version
* @returns {void}
*/
export function logSuppressedVersion(packageName, version) {
recordSuppressedVersion();
ui.writeVerbose(
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
);
}

View file

@ -0,0 +1,131 @@
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
/**
* @param {any} file
* @param {string} metadataUrl
* @returns {string | undefined}
*/
export function getPackageVersionFromMetadataFile(file, metadataUrl) {
const href = typeof file?.url === "string" ? file.url : undefined;
const filename = typeof file?.filename === "string" ? file.filename : undefined;
if (href) {
const resolvedHref = new URL(href, metadataUrl).toString();
return parsePipPackageFromUrl(
resolvedHref,
new URL(resolvedHref).host
).version;
}
if (filename) {
return parsePipPackageFromUrl(
new URL(filename, metadataUrl).toString(),
new URL(metadataUrl).host
).version;
}
return undefined;
}
/**
* @param {any} json
* @param {string} metadataUrl
* @returns {string[]}
*/
export function getAvailableVersionsFromJson(json, metadataUrl) {
if (json.releases && typeof json.releases === "object") {
return Object.keys(json.releases);
}
if (!Array.isArray(json.files)) {
return [];
}
return [
...new Set(
json.files
.map((/** @type {any} */ file) =>
getPackageVersionFromMetadataFile(file, metadataUrl)
)
.filter(isDefinedString)
),
];
}
/**
* @param {string | undefined} value
* @returns {value is string}
*/
function isDefinedString(value) {
return typeof value === "string";
}
/**
* @param {string[]} versions
* @returns {string | undefined}
*/
export function calculateLatestVersion(versions) {
const stableVersions = versions.filter((version) => !isPrerelease(version));
if (stableVersions.length > 0) {
return stableVersions.sort(comparePep440ishVersions).at(-1);
}
return versions.sort(comparePep440ishVersions).at(-1);
}
/**
* @param {string} left
* @param {string} right
* @returns {number}
*/
function comparePep440ishVersions(left, right) {
const leftParts = tokenizeVersion(left);
const rightParts = tokenizeVersion(right);
const maxLength = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < maxLength; index += 1) {
const leftPart = leftParts[index];
const rightPart = rightParts[index];
if (leftPart === undefined) return -1;
if (rightPart === undefined) return 1;
if (leftPart === rightPart) {
continue;
}
const leftNumeric = typeof leftPart === "number";
const rightNumeric = typeof rightPart === "number";
if (leftNumeric && rightNumeric) {
return leftPart - rightPart;
}
if (leftNumeric) return 1;
if (rightNumeric) return -1;
return String(leftPart).localeCompare(String(rightPart));
}
return 0;
}
/**
* @param {string} version
* @returns {(string | number)[]}
*/
function tokenizeVersion(version) {
return version
.toLowerCase()
.split(/[^a-z0-9]+/)
.flatMap((part) => part.match(/[a-z]+|\d+/g) || [])
.map((part) => (/^\d+$/.test(part) ? Number(part) : part));
}
/**
* @param {string} version
* @returns {boolean}
*/
function isPrerelease(version) {
return /(a|b|rc|dev)\d+/i.test(version);
}

View file

@ -1,132 +0,0 @@
import { getPipCustomRegistries } from "../../config/settings.js";
import { isMalwarePackage } from "../../scanning/audit/index.js";
import { interceptRequests } from "./interceptorBuilder.js";
const knownPipRegistries = [
"files.pythonhosted.org",
"pypi.org",
"pypi.python.org",
"pythonhosted.org",
];
/**
* @param {string} url
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
export function pipInterceptorForUrl(url) {
const customRegistries = getPipCustomRegistries();
const registries = [...knownPipRegistries, ...customRegistries];
const registry = registries.find((reg) => url.includes(reg));
if (registry) {
return buildPipInterceptor(registry);
}
return undefined;
}
/**
* @param {string} registry
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
*/
function buildPipInterceptor(registry) {
return interceptRequests(async (reqContext) => {
const { packageName, version } = parsePipPackageFromUrl(
reqContext.targetUrl,
registry
);
// Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names.
// Per python, packages that differ only by hyphen vs underscore are considered the same.
const hyphenName = packageName?.includes("_") ? packageName.replace(/_/g, "-") : packageName;
const isMalicious =
await isMalwarePackage(packageName, version)
|| await isMalwarePackage(hyphenName, version);
if (isMalicious) {
reqContext.blockMalware(packageName, version);
}
});
}
/**
* @param {string} url
* @param {string} registry
* @returns {{packageName: string | undefined, version: string | undefined}}
*/
function parsePipPackageFromUrl(url, registry) {
let packageName, version;
// Basic validation
if (!registry || typeof url !== "string") {
return { packageName, version };
}
// Quick sanity check on the URL + parse
let urlObj;
try {
urlObj = new URL(url);
} catch {
return { packageName, version };
}
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
if (!lastSegment) {
return { packageName, version };
}
const filename = decodeURIComponent(lastSegment);
// Parse Python package downloads from PyPI/pythonhosted.org
// Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
// Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
// Wheel (.whl) and Poetry's preflight metadata (.whl.metadata)
// Examples:
// foo_bar-2.0.0-py3-none-any.whl
// foo_bar-2.0.0-py3-none-any.whl.metadata
const wheelExtRe = /\.whl(?:\.metadata)?$/;
const wheelExtMatch = filename.match(wheelExtRe);
if (wheelExtMatch) {
const base = filename.replace(wheelExtRe, "");
const firstDash = base.indexOf("-");
if (firstDash > 0) {
const dist = base.slice(0, firstDash); // may contain underscores
const rest = base.slice(firstDash + 1); // version + the rest of tags
const secondDash = rest.indexOf("-");
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
packageName = dist;
version = rawVersion;
// Reject "latest" as it's a placeholder, not a real version
// When version is "latest", this signals the URL doesn't contain actual version info
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
}
// Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata)
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
if (sdistExtMatch) {
const base = filename.replace(sdistExtWithMetadataRe, "");
const lastDash = base.lastIndexOf("-");
if (lastDash > 0 && lastDash < base.length - 1) {
packageName = base.slice(0, lastDash);
version = base.slice(lastDash + 1);
// Reject "latest" as it's a placeholder, not a real version
// When version is "latest", this signals the URL doesn't contain actual version info
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
if (version === "latest" || !packageName || !version) {
return { packageName: undefined, version: undefined };
}
return { packageName, version };
}
}
// Unknown file type or invalid
return { packageName: undefined, version: undefined };
}

View file

@ -0,0 +1,21 @@
const state = {
hasSuppressedVersions: false,
};
/**
* Tracks whether any rewritten metadata response suppressed versions during the
* current process lifetime. This is intentional shared state used only for the
* end-of-run summary message exposed through the proxy API.
*
* @returns {void}
*/
export function recordSuppressedVersion() {
state.hasSuppressedVersions = true;
}
/**
* @returns {boolean}
*/
export function getHasSuppressedVersions() {
return state.hasSuppressedVersions;
}

View file

@ -2,7 +2,8 @@ import https from "https";
import { generateCertForHost } from "./certUtils.js"; import { generateCertForHost } from "./certUtils.js";
import { HttpsProxyAgent } from "https-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { gunzipSync, gzipSync } from "zlib"; import { gunzipSync } from "zlib";
import { omitHeaders } from "./http-utils.js";
/** /**
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor * @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
@ -215,11 +216,16 @@ function createProxyRequest(hostname, port, req, res, requestHandler) {
buffer = requestHandler.modifyBody(buffer, headers); buffer = requestHandler.modifyBody(buffer, headers);
if (proxyRes.headers["content-encoding"] === "gzip") { // For rewritten responses, send the final body uncompressed.
buffer = gzipSync(buffer); // This avoids mismatches between upstream compression metadata and the
} // rewritten payload on the wire.
const rewrittenHeaders = omitHeaders(
res.writeHead(statusCode, headers); headers,
["content-length", "transfer-encoding", "content-encoding"],
{ caseInsensitive: true }
) || {};
rewrittenHeaders["content-length"] = String(buffer.byteLength);
res.writeHead(statusCode, rewrittenHeaders);
res.end(buffer); res.end(buffer);
}); });
} else { } else {

View file

@ -0,0 +1,138 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
import zlib from "node:zlib";
describe("mitmRequestHandler", async () => {
let capturedHandler;
let capturedOptions;
mock.module("https", {
defaultExport: {
createServer: (_options, handler) => {
capturedHandler = handler;
return {
on: () => {},
emit: () => {},
};
},
request: (options, callback) => {
capturedOptions = options;
const listeners = {};
const proxyRes = {
statusCode: 200,
headers: {
"content-encoding": "gzip",
"content-length": "999",
"transfer-encoding": "chunked",
},
on: (event, handler) => {
listeners[event] = handler;
},
};
callback(proxyRes);
return {
on: () => {},
write: () => {},
end: () => {
const payload = Buffer.from("rewritten body");
listeners["data"]?.(zlib.gzipSync(payload));
listeners["end"]?.();
},
destroy: () => {},
};
},
},
});
mock.module("./certUtils.js", {
namedExports: {
generateCertForHost: () => ({
privateKey: "key",
certificate: "cert",
}),
},
});
mock.module("https-proxy-agent", {
namedExports: {
HttpsProxyAgent: class {},
},
});
mock.module("../environment/userInteraction.js", {
namedExports: {
ui: {
writeVerbose: () => {},
writeError: () => {},
},
},
});
const { mitmConnect } = await import("./mitmRequestHandler.js");
it("sets content-length from the final compressed payload after body rewrite", async () => {
const interceptor = {
handleRequest: async () => ({
blockResponse: undefined,
modifyRequestHeaders: (headers) => headers,
modifiesResponse: () => true,
modifyBody: () => Buffer.from("rewritten body"),
}),
};
const req = {
url: "pypi.org:443",
};
const clientSocket = {
on: () => {},
write: () => {},
headersSent: false,
writable: true,
end: () => {},
};
mitmConnect(req, clientSocket, interceptor);
const resState = {
statusCode: undefined,
headers: undefined,
body: undefined,
};
const res = {
headersSent: false,
writeHead: (statusCode, headers) => {
resState.statusCode = statusCode;
resState.headers = headers;
},
end: (body) => {
resState.body = body;
},
};
const request = {
url: "/simple/example/",
headers: {},
method: "GET",
on: (event, handler) => {
if (event === "end") {
handler();
}
},
};
await capturedHandler(request, res);
assert.equal(capturedOptions.hostname, "pypi.org");
assert.equal(resState.statusCode, 200);
assert.equal(resState.headers["transfer-encoding"], undefined);
assert.equal(
resState.headers["content-length"],
String(resState.body.byteLength)
);
});
});

View file

@ -6,15 +6,20 @@ import { getCombinedCaBundlePath, cleanupCertBundle } from "./certBundle.js";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import chalk from "chalk"; import chalk from "chalk";
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js"; import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js"; import { getHasSuppressedVersions } from "./interceptors/suppressedVersionsState.js";
const SERVER_STOP_TIMEOUT_MS = 1000; const SERVER_STOP_TIMEOUT_MS = 1000;
/** /**
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}} * @type {{
* port: number | null,
* blockedRequests: {packageName: string, version: string, url: string}[],
* blockedMinimumAgeRequests: {packageName: string, version: string, url: string}[]
* }}
*/ */
const state = { const state = {
port: null, port: null,
blockedRequests: [], blockedRequests: [],
blockedMinimumAgeRequests: [],
}; };
export function createSafeChainProxy() { export function createSafeChainProxy() {
@ -23,7 +28,8 @@ export function createSafeChainProxy() {
return { return {
startServer: () => startServer(server), startServer: () => startServer(server),
stopServer: () => stopServer(server), stopServer: () => stopServer(server),
verifyNoMaliciousPackages, hasBlockedMaliciousPackages,
hasBlockedMinimumAgeRequests,
hasSuppressedVersions: getHasSuppressedVersions, hasSuppressedVersions: getHasSuppressedVersions,
}; };
} }
@ -36,7 +42,7 @@ function getSafeChainProxyEnvironmentVariables() {
return {}; return {};
} }
const proxyUrl = `http://localhost:${state.port}`; const proxyUrl = `http://127.0.0.1:${state.port}`;
const caCertPath = getCombinedCaBundlePath(); const caCertPath = getCombinedCaBundlePath();
return { return {
@ -89,8 +95,11 @@ function createProxyServer() {
*/ */
function startServer(server) { function startServer(server) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Passing port 0 makes the OS assign an available port // Bind to loopback only. Without an explicit host, Node listens on every
server.listen(0, () => { // 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(); const address = server.address();
if (address && typeof address === "object") { if (address && typeof address === "object") {
state.port = address.port; state.port = address.port;
@ -151,6 +160,18 @@ function handleConnect(req, clientSocket, head) {
onMalwareBlocked(event.packageName, event.version, event.targetUrl); onMalwareBlocked(event.packageName, event.version, event.targetUrl);
} }
); );
interceptor.on(
"minimumAgeRequestBlocked",
(
/** @type {import("./interceptors/interceptorBuilder.js").MinimumAgeRequestBlockedEvent} */ event
) => {
onMinimumAgeRequestBlocked(
event.packageName,
event.version,
event.targetUrl
);
}
);
mitmConnect(req, clientSocket, interceptor); mitmConnect(req, clientSocket, interceptor);
} else { } else {
@ -170,10 +191,19 @@ function onMalwareBlocked(packageName, version, url) {
state.blockedRequests.push({ packageName, version, url }); state.blockedRequests.push({ packageName, version, url });
} }
function verifyNoMaliciousPackages() { /**
*
* @param {string} packageName
* @param {string} version
* @param {string} url
*/
function onMinimumAgeRequestBlocked(packageName, version, url) {
state.blockedMinimumAgeRequests.push({ packageName, version, url });
}
function hasBlockedMaliciousPackages() {
if (state.blockedRequests.length === 0) { if (state.blockedRequests.length === 0) {
// No malicious packages were blocked, so nothing to block return false;
return true;
} }
ui.emptyLine(); ui.emptyLine();
@ -192,5 +222,37 @@ function verifyNoMaliciousPackages() {
ui.writeExitWithoutInstallingMaliciousPackages(); ui.writeExitWithoutInstallingMaliciousPackages();
ui.emptyLine(); ui.emptyLine();
return false; return true;
}
function hasBlockedMinimumAgeRequests() {
if (state.blockedMinimumAgeRequests.length === 0) {
return false;
}
ui.emptyLine();
ui.writeInformation(
`Safe-chain: ${chalk.bold(
`blocked ${state.blockedMinimumAgeRequests.length} direct package download request(s) due to minimum package age`
)}:`
);
for (const req of state.blockedMinimumAgeRequests) {
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
}
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age"
)}`
);
ui.emptyLine();
ui.writeError(
"Safe-chain: Exiting without installing packages blocked by the direct download minimum package age check."
);
ui.emptyLine();
return true;
} }

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 * @property {function(string, string): boolean} isMalware
*/ */
/** @type {MalwareDatabase | null} */ // Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
let cachedMalwareDatabase = null; // 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. * Normalize package name for comparison.
@ -34,13 +38,9 @@ function normalizePackageName(name) {
return name; return name;
} }
export async function openMalwareDatabase() { export function openMalwareDatabase() {
if (cachedMalwareDatabase) { if (!cachedMalwareDatabasePromise) {
return cachedMalwareDatabase; cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => {
}
const malwareDatabase = await getMalwareDatabase();
/** /**
* @param {string} name * @param {string} name
* @param {string} version * @param {string} version
@ -63,16 +63,19 @@ export async function openMalwareDatabase() {
return packageData.reason; return packageData.reason;
} }
// This implicitly caches the malware database return {
// that's closed over by the getPackageStatus function
cachedMalwareDatabase = {
getPackageStatus, getPackageStatus,
isMalware: (name, version) => { isMalware: (/** @type {string} */ name, /** @type {string} */ version) => {
const status = getPackageStatus(name, version); const status = getPackageStatus(name, version);
return isMalwareStatus(status); return isMalwareStatus(status);
}, },
}; };
return cachedMalwareDatabase; }).catch((error) => {
cachedMalwareDatabasePromise = null;
throw error;
});
}
return cachedMalwareDatabasePromise;
} }
/** /**

View file

@ -0,0 +1,267 @@
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert";
import fs from "fs";
import path from "path";
import os from "os";
// --- shared mutable state for mocks ---
let fetchedList = [];
let fetchedVersion = "etag-1";
let fetchVersionResult = "etag-1";
let minimumPackageAgeHours = 24;
let ecosystem = "js";
let writeWarningCalls = [];
let fetchListError = null;
let fetchVersionError = null;
let importCounter = 0;
let testHomeDir = "";
mock.module("../api/aikido.js", {
namedExports: {
fetchNewPackagesList: async () => {
if (fetchListError) {
throw fetchListError;
}
return {
newPackagesList: fetchedList,
version: fetchedVersion,
};
},
fetchNewPackagesListVersion: async () => {
if (fetchVersionError) {
throw fetchVersionError;
}
return fetchVersionResult;
},
},
});
mock.module("../environment/userInteraction.js", {
namedExports: {
ui: {
writeWarning: (msg) => writeWarningCalls.push(msg),
writeVerbose: () => {},
},
},
});
mock.module("../config/settings.js", {
namedExports: {
getMinimumPackageAgeHours: () => minimumPackageAgeHours,
getEcoSystem: () => ecosystem,
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
},
});
// Import the warnings module so we can reset its state between tests.
const { resetWarningState } = await import("./newPackagesDatabaseWarnings.js");
describe("newPackagesDatabase", async () => {
beforeEach(() => {
fetchedList = [];
fetchedVersion = "etag-1";
fetchVersionResult = "etag-1";
minimumPackageAgeHours = 24;
ecosystem = "js";
writeWarningCalls = [];
fetchListError = null;
fetchVersionError = null;
resetWarningState();
testHomeDir = path.join(
os.tmpdir(),
`safe-chain-new-packages-db-${process.pid}-${importCounter}`
);
fs.rmSync(testHomeDir, { recursive: true, force: true });
fs.mkdirSync(testHomeDir, { recursive: true });
process.env.HOME = testHomeDir;
});
async function openNewPackagesDatabase() {
const module = await import(
`./newPackagesListCache.js?test_case=${importCounter++}`
);
return module.openNewPackagesDatabase();
}
async function loadNewPackagesDatabaseModule() {
return import(`./newPackagesListCache.js?test_case=${importCounter++}`);
}
function hoursAgo(hours) {
return Math.floor((Date.now() - hours * 3600 * 1000) / 1000);
}
function writeCachedList(list, version) {
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
fs.writeFileSync(
path.join(safeChainDir, `newPackagesList_${ecosystem}.json`),
JSON.stringify(list)
);
fs.writeFileSync(
path.join(safeChainDir, `newPackagesList_version_${ecosystem}.txt`),
version
);
}
describe("isNewlyReleasedPackage", () => {
it("returns true for a package released within the age threshold", async () => {
fetchedList = [
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
});
it("returns false for a package released outside the age threshold", async () => {
fetchedList = [
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(48) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
});
it("returns false for a package not in the list", async () => {
fetchedList = [];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("not-there", "1.0.0"), false);
});
it("returns false for a known package but different version", async () => {
fetchedList = [
{ package_name: "foo", version: "2.0.0", released_on: hoursAgo(1) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
});
it("matches the current feed ecosystem when source metadata is present", async () => {
fetchedList = [
{
source: "pypi",
package_name: "foo",
version: "1.0.0",
released_on: hoursAgo(1),
},
{
source: "npm",
package_name: "bar",
version: "1.0.0",
released_on: hoursAgo(1),
},
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
assert.strictEqual(db.isNewlyReleasedPackage("bar", "1.0.0"), true);
});
it("respects a custom minimumPackageAgeHours threshold", async () => {
minimumPackageAgeHours = 168; // 7 days
fetchedList = [
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(100) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
});
it("supports package checks for the python ecosystem", async () => {
ecosystem = "py";
fetchedList = [
{
source: "pypi",
package_name: "foo",
version: "1.0.0",
released_on: hoursAgo(1),
},
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
});
});
describe("caching behaviour", () => {
it("uses local cache when etag matches", async () => {
writeCachedList([
{ package_name: "cached-pkg", version: "1.0.0", released_on: hoursAgo(1) },
], "etag-1");
fetchVersionResult = "etag-1";
// fetchedList is empty — if we used the remote list, the lookup would return false
fetchedList = [];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true);
});
it("fetches fresh list when etag does not match", async () => {
writeCachedList([
{ package_name: "stale-pkg", version: "1.0.0", released_on: hoursAgo(1) },
], "etag-old");
fetchVersionResult = "etag-new";
fetchedList = [
{ package_name: "fresh-pkg", version: "2.0.0", released_on: hoursAgo(1) },
];
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("stale-pkg", "1.0.0"), false);
assert.strictEqual(db.isNewlyReleasedPackage("fresh-pkg", "2.0.0"), true);
});
it("falls back to local cache when fetch fails", async () => {
writeCachedList([
{
package_name: "cached-pkg",
version: "1.0.0",
released_on: hoursAgo(1),
},
], "etag-old");
fetchVersionResult = "etag-new";
fetchListError = new Error("Network error");
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("cached-pkg", "1.0.0"), true);
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(writeWarningCalls[0].includes("Using cached version"));
});
it("emits a warning when list has no version (cannot be cached)", async () => {
fetchedList = [
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) },
];
fetchedVersion = undefined;
const db = await openNewPackagesDatabase();
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(writeWarningCalls[0].includes("could not be cached"));
});
it("fails open and only warns once when the new packages list cannot be loaded", async () => {
fetchListError = new Error("feed unavailable");
const module = await loadNewPackagesDatabaseModule();
const db1 = await module.openNewPackagesDatabase();
const db2 = await module.openNewPackagesDatabase();
assert.strictEqual(db1.isNewlyReleasedPackage("foo", "1.0.0"), false);
assert.strictEqual(db2.isNewlyReleasedPackage("foo", "1.0.0"), false);
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(
writeWarningCalls[0].includes(
"Continuing with metadata-based minimum age checks only"
)
);
});
});
});

View file

@ -0,0 +1,71 @@
import {
getMinimumPackageAgeHours,
getEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
} from "../config/settings.js";
import { getEquivalentPackageNames } from "./packageNameVariants.js";
/**
* @typedef {Object} NewPackagesDatabase
* @property {function(string | undefined, string | undefined): boolean} isNewlyReleasedPackage
*/
/**
* Returns the ecosystem identifier expected in upstream/core release feeds.
* @returns {string}
*/
function getCurrentFeedSource() {
const ecosystem = getEcoSystem();
if (ecosystem === ECOSYSTEM_JS) {
return "npm";
}
if (ecosystem === ECOSYSTEM_PY) {
return "pypi";
}
return ecosystem;
}
/**
* @param {import("../api/aikido.js").NewPackageEntry[]} newPackagesList
* @returns {NewPackagesDatabase}
*/
export function buildNewPackagesDatabase(newPackagesList) {
const ecosystem = getEcoSystem();
/**
* @param {string | undefined} name
* @param {string | undefined} version
* @returns {boolean}
*/
function isNewlyReleasedPackage(name, version) {
if (!name || !version) {
return false;
}
const cutOff = new Date(
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
);
const expectedSource = getCurrentFeedSource();
const candidateNames = getEquivalentPackageNames(name, ecosystem);
const entry = newPackagesList.find(
(pkg) =>
(!pkg.source || pkg.source.toLowerCase() === expectedSource) &&
candidateNames.includes(pkg.package_name) &&
pkg.version === version
);
if (!entry) {
return false;
}
const releasedOn = new Date(entry.released_on * 1000);
return releasedOn > cutOff;
}
return { isNewlyReleasedPackage };
}

View file

@ -0,0 +1,159 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
let minimumPackageAgeHours = 24;
let ecosystem = "js";
mock.module("../config/settings.js", {
namedExports: {
getMinimumPackageAgeHours: () => minimumPackageAgeHours,
getEcoSystem: () => ecosystem,
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
},
});
const { buildNewPackagesDatabase } = await import(
"./newPackagesDatabaseBuilder.js"
);
function hoursAgo(hours) {
return Math.floor((Date.now() - hours * 3600 * 1000) / 1000);
}
describe("buildNewPackagesDatabase", () => {
it("returns an object with isNewlyReleasedPackage", () => {
const db = buildNewPackagesDatabase([]);
assert.strictEqual(typeof db.isNewlyReleasedPackage, "function");
});
describe("isNewlyReleasedPackage", () => {
it("returns true for a package released within the age threshold", () => {
const db = buildNewPackagesDatabase([
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
});
it("returns false for a package released outside the age threshold", () => {
const db = buildNewPackagesDatabase([
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(48) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
});
it("returns false for a package not in the list", () => {
const db = buildNewPackagesDatabase([]);
assert.strictEqual(db.isNewlyReleasedPackage("not-there", "1.0.0"), false);
});
it("returns false when name or version is undefined", () => {
const db = buildNewPackagesDatabase([
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) },
]);
assert.strictEqual(db.isNewlyReleasedPackage(undefined, "1.0.0"), false);
assert.strictEqual(db.isNewlyReleasedPackage("foo", undefined), false);
});
it("returns false for a known package but different version", () => {
const db = buildNewPackagesDatabase([
{ package_name: "foo", version: "2.0.0", released_on: hoursAgo(1) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
});
it("filters by source when source metadata is present", () => {
const db = buildNewPackagesDatabase([
{ source: "pypi", package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) },
{ source: "npm", package_name: "bar", version: "1.0.0", released_on: hoursAgo(1) },
]);
// ecosystem is "js" → feed source is "npm"
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), false);
assert.strictEqual(db.isNewlyReleasedPackage("bar", "1.0.0"), true);
});
it("matches regardless of source case", () => {
const db = buildNewPackagesDatabase([
{ source: "NPM", package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
});
it("matches entries with no source field", () => {
const db = buildNewPackagesDatabase([
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(1) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
});
it("respects a custom minimumPackageAgeHours threshold", () => {
minimumPackageAgeHours = 168; // 7 days
const db = buildNewPackagesDatabase([
{ package_name: "foo", version: "1.0.0", released_on: hoursAgo(100) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo", "1.0.0"), true);
minimumPackageAgeHours = 24; // reset
});
it("matches underscore request names against hyphen feed names for python", () => {
ecosystem = "py";
const db = buildNewPackagesDatabase([
{ source: "pypi", package_name: "foo-bar", version: "1.0.0", released_on: hoursAgo(1) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo_bar", "1.0.0"), true);
ecosystem = "js";
});
it("matches hyphen request names against underscore feed names for python", () => {
ecosystem = "py";
const db = buildNewPackagesDatabase([
{ source: "pypi", package_name: "foo_bar", version: "1.0.0", released_on: hoursAgo(1) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo-bar", "1.0.0"), true);
ecosystem = "js";
});
it("matches dot request names against hyphen feed names for python", () => {
ecosystem = "py";
const db = buildNewPackagesDatabase([
{ source: "pypi", package_name: "foo-bar", version: "1.0.0", released_on: hoursAgo(1) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo.bar", "1.0.0"), true);
ecosystem = "js";
});
it("matches underscore request names against dot feed names for python", () => {
ecosystem = "py";
const db = buildNewPackagesDatabase([
{ source: "pypi", package_name: "foo.bar", version: "1.0.0", released_on: hoursAgo(1) },
]);
assert.strictEqual(db.isNewlyReleasedPackage("foo_bar", "1.0.0"), true);
ecosystem = "js";
});
});
});

View file

@ -0,0 +1,17 @@
import { ui } from "../environment/userInteraction.js";
let hasWarnedAboutUnavailableNewPackagesDatabase = false;
/** @param {Error} error */
export function warnOnceAboutUnavailableDatabase(error) {
if (!hasWarnedAboutUnavailableNewPackagesDatabase) {
ui.writeWarning(
`Failed to load the new packages list used for direct package download request blocking. Continuing with metadata-based minimum age checks only. ${error.message}`
);
hasWarnedAboutUnavailableNewPackagesDatabase = true;
}
}
export function resetWarningState() {
hasWarnedAboutUnavailableNewPackagesDatabase = false;
}

View file

@ -0,0 +1,63 @@
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert";
let writeWarningCalls = [];
mock.module("../environment/userInteraction.js", {
namedExports: {
ui: {
writeWarning: (msg) => writeWarningCalls.push(msg),
},
},
});
const { warnOnceAboutUnavailableDatabase, resetWarningState } = await import(
"./newPackagesDatabaseWarnings.js"
);
describe("newPackagesDatabaseWarnings", () => {
beforeEach(() => {
writeWarningCalls = [];
resetWarningState();
});
describe("warnOnceAboutUnavailableDatabase", () => {
it("emits a warning containing the error message", () => {
warnOnceAboutUnavailableDatabase(new Error("feed unavailable"));
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(writeWarningCalls[0].includes("feed unavailable"));
});
it("mentions fallback to metadata-based checks in the warning", () => {
warnOnceAboutUnavailableDatabase(new Error("timeout"));
assert.ok(
writeWarningCalls[0].includes(
"Continuing with metadata-based minimum age checks only"
)
);
});
it("only emits once even when called multiple times", () => {
warnOnceAboutUnavailableDatabase(new Error("first"));
warnOnceAboutUnavailableDatabase(new Error("second"));
warnOnceAboutUnavailableDatabase(new Error("third"));
assert.strictEqual(writeWarningCalls.length, 1);
});
});
describe("resetWarningState", () => {
it("allows the warning to fire again after reset", () => {
warnOnceAboutUnavailableDatabase(new Error("first"));
assert.strictEqual(writeWarningCalls.length, 1);
resetWarningState();
writeWarningCalls = [];
warnOnceAboutUnavailableDatabase(new Error("second"));
assert.strictEqual(writeWarningCalls.length, 1);
});
});
});

View file

@ -0,0 +1,123 @@
import fs from "fs";
import {
fetchNewPackagesList,
fetchNewPackagesListVersion,
} from "../api/aikido.js";
import {
getNewPackagesListPath,
getNewPackagesListVersionPath,
} from "../config/configFile.js";
import { ui } from "../environment/userInteraction.js";
import { buildNewPackagesDatabase } from "./newPackagesDatabaseBuilder.js";
import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.js";
/**
* @typedef {import("./newPackagesDatabaseBuilder.js").NewPackagesDatabase} NewPackagesDatabase
*/
// Shared per-process cache to avoid rebuilding the same feed-backed database on each request.
// 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 function openNewPackagesDatabase() {
if (!cachedNewPackagesDatabasePromise) {
cachedNewPackagesDatabasePromise = getNewPackagesList()
.then((newPackagesList) => buildNewPackagesDatabase(newPackagesList))
.catch((/** @type {any} */ error) => {
warnOnceAboutUnavailableDatabase(error);
cachedNewPackagesDatabasePromise = null;
return { isNewlyReleasedPackage: () => false };
});
}
return cachedNewPackagesDatabasePromise;
}
/**
* @returns {Promise<import("../api/aikido.js").NewPackageEntry[]>}
*/
async function getNewPackagesList() {
const { newPackagesList: cachedList, version: cachedVersion } =
readNewPackagesListFromLocalCache();
try {
if (cachedList) {
const currentVersion = await fetchNewPackagesListVersion();
if (cachedVersion === currentVersion) {
return cachedList;
}
}
const { newPackagesList, version } = await fetchNewPackagesList();
if (version) {
writeNewPackagesListToLocalCache(newPackagesList, version);
return newPackagesList;
} else {
ui.writeWarning(
"The new packages list for direct package download request blocking was downloaded, but could not be cached due to a missing version."
);
return newPackagesList;
}
} catch (/** @type {any} */ error) {
if (cachedList) {
ui.writeWarning(
"Failed to fetch the latest new packages list for direct package download request blocking. Using cached version."
);
return cachedList;
}
throw error;
}
}
/**
* @param {import("../api/aikido.js").NewPackageEntry[]} data
* @param {string | number} version
*
* @returns {void}
*/
export function writeNewPackagesListToLocalCache(data, version) {
try {
const listPath = getNewPackagesListPath();
const versionPath = getNewPackagesListVersionPath();
fs.writeFileSync(listPath, JSON.stringify(data));
fs.writeFileSync(versionPath, version.toString());
} catch {
ui.writeWarning(
"Failed to write new packages list to local cache, next time the list will be fetched from the server again."
);
}
}
/**
* @returns {{newPackagesList: import("../api/aikido.js").NewPackageEntry[] | null, version: string | null}}
*/
export function readNewPackagesListFromLocalCache() {
try {
const listPath = getNewPackagesListPath();
if (!fs.existsSync(listPath)) {
return { newPackagesList: null, version: null };
}
const data = fs.readFileSync(listPath, "utf8");
const newPackagesList = JSON.parse(data);
const versionPath = getNewPackagesListVersionPath();
let version = null;
if (fs.existsSync(versionPath)) {
version = fs.readFileSync(versionPath, "utf8").trim();
}
return { newPackagesList, version };
} catch {
ui.writeWarning(
"Failed to read new packages list from local cache. Continuing without local cache."
);
return { newPackagesList: null, version: null };
}
}

View file

@ -0,0 +1,178 @@
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert";
import fs from "fs";
import path from "path";
import os from "os";
let writeWarningCalls = [];
let ecosystem = "js";
let testHomeDir = "";
mock.module("../environment/userInteraction.js", {
namedExports: {
ui: {
writeWarning: (msg) => writeWarningCalls.push(msg),
},
},
});
mock.module("../config/settings.js", {
namedExports: {
getEcoSystem: () => ecosystem,
getMinimumPackageAgeHours: () => 24,
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
},
});
const { readNewPackagesListFromLocalCache, writeNewPackagesListToLocalCache } =
await import("./newPackagesListCache.js");
describe("newPackagesListCache", () => {
beforeEach(() => {
writeWarningCalls = [];
ecosystem = "js";
testHomeDir = path.join(
os.tmpdir(),
`safe-chain-list-cache-${process.pid}-${Date.now()}`
);
fs.rmSync(testHomeDir, { recursive: true, force: true });
fs.mkdirSync(testHomeDir, { recursive: true });
process.env.HOME = testHomeDir;
});
describe("readNewPackagesListFromLocalCache", () => {
it("returns null for both fields when no cache file exists", () => {
const result = readNewPackagesListFromLocalCache();
assert.deepStrictEqual(result, { newPackagesList: null, version: null });
});
it("returns the list and version when both files exist", () => {
const list = [{ package_name: "foo", version: "1.0.0" }];
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
fs.writeFileSync(
path.join(safeChainDir, "newPackagesList_js.json"),
JSON.stringify(list)
);
fs.writeFileSync(
path.join(safeChainDir, "newPackagesList_version_js.txt"),
"etag-42"
);
const result = readNewPackagesListFromLocalCache();
assert.deepStrictEqual(result.newPackagesList, list);
assert.strictEqual(result.version, "etag-42");
});
it("returns null version when version file is missing", () => {
const list = [{ package_name: "foo", version: "1.0.0" }];
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
fs.writeFileSync(
path.join(safeChainDir, "newPackagesList_js.json"),
JSON.stringify(list)
);
const result = readNewPackagesListFromLocalCache();
assert.deepStrictEqual(result.newPackagesList, list);
assert.strictEqual(result.version, null);
});
it("trims whitespace from the version string", () => {
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
fs.writeFileSync(
path.join(safeChainDir, "newPackagesList_js.json"),
JSON.stringify([])
);
fs.writeFileSync(
path.join(safeChainDir, "newPackagesList_version_js.txt"),
" etag-trimmed \n"
);
const { version } = readNewPackagesListFromLocalCache();
assert.strictEqual(version, "etag-trimmed");
});
it("uses the ecosystem name in the file path", () => {
ecosystem = "py";
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
fs.writeFileSync(
path.join(safeChainDir, "newPackagesList_py.json"),
JSON.stringify([{ package_name: "requests", version: "2.0.0" }])
);
const result = readNewPackagesListFromLocalCache();
assert.ok(result.newPackagesList !== null);
});
it("warns and returns nulls when the list file contains invalid JSON", () => {
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
fs.writeFileSync(
path.join(safeChainDir, "newPackagesList_js.json"),
"not-valid-json"
);
const result = readNewPackagesListFromLocalCache();
assert.deepStrictEqual(result, { newPackagesList: null, version: null });
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(writeWarningCalls[0].includes("local cache"));
});
});
describe("writeNewPackagesListToLocalCache", () => {
it("writes the list and version to disk", () => {
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
const list = [{ package_name: "foo", version: "1.0.0" }];
writeNewPackagesListToLocalCache(list, "etag-99");
const writtenList = JSON.parse(
fs.readFileSync(path.join(safeChainDir, "newPackagesList_js.json"), "utf8")
);
const writtenVersion = fs.readFileSync(
path.join(safeChainDir, "newPackagesList_version_js.txt"),
"utf8"
);
assert.deepStrictEqual(writtenList, list);
assert.strictEqual(writtenVersion, "etag-99");
});
it("converts a numeric version to a string", () => {
const safeChainDir = path.join(testHomeDir, ".safe-chain");
fs.mkdirSync(safeChainDir, { recursive: true });
writeNewPackagesListToLocalCache([], 42);
const written = fs.readFileSync(
path.join(safeChainDir, "newPackagesList_version_js.txt"),
"utf8"
);
assert.strictEqual(written, "42");
});
it("warns when writing fails", () => {
// Place a regular file at the .safe-chain path so getSafeChainDirectory
// returns it as-is (existsSync is true) but writing a child path fails.
const safeChainPath = path.join(testHomeDir, ".safe-chain");
fs.writeFileSync(safeChainPath, "not-a-directory");
writeNewPackagesListToLocalCache([], "etag-fail");
assert.strictEqual(writeWarningCalls.length, 1);
assert.ok(writeWarningCalls[0].includes("local cache"));
});
});
});

View file

@ -0,0 +1,29 @@
import { ECOSYSTEM_PY } from "../config/settings.js";
/**
* Normalises a Python package name per PEP 503: lowercase and collapse any
* run of `.`, `_`, or `-` into a single hyphen.
* @param {string} packageName
* @returns {string}
*/
export function normalizePipPackageName(packageName) {
return packageName.toLowerCase().replace(/[._-]+/g, "-");
}
/**
* @param {string} packageName
* @param {string} ecosystem
* @returns {string[]}
*/
export function getEquivalentPackageNames(packageName, ecosystem) {
if (ecosystem !== ECOSYSTEM_PY) {
return [packageName];
}
const pythonSeparatorPattern = /[._-]/g;
const hyphenName = packageName.replaceAll(pythonSeparatorPattern, "-");
const underscoreName = packageName.replaceAll(pythonSeparatorPattern, "_");
const dotName = packageName.replaceAll(pythonSeparatorPattern, ".");
return [...new Set([packageName, hyphenName, underscoreName, dotName])];
}

View file

@ -48,6 +48,18 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_JS, ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "pnpx", internalPackageManagerName: "pnpx",
}, },
{
tool: "rush",
aikidoCommand: "aikido-rush",
ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "rush",
},
{
tool: "rushx",
aikidoCommand: "aikido-rushx",
ecoSystem: ECOSYSTEM_JS,
internalPackageManagerName: "rushx",
},
{ {
tool: "bun", tool: "bun",
aikidoCommand: "aikido-bun", aikidoCommand: "aikido-bun",
@ -66,6 +78,12 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_PY, ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "uv", internalPackageManagerName: "uv",
}, },
{
tool: "uvx",
aikidoCommand: "aikido-uvx",
ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "uvx",
},
{ {
tool: "pip", tool: "pip",
aikidoCommand: "aikido-pip", aikidoCommand: "aikido-pip",
@ -102,6 +120,12 @@ export const knownAikidoTools = [
ecoSystem: ECOSYSTEM_PY, ecoSystem: ECOSYSTEM_PY,
internalPackageManagerName: "pipx", 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 // When adding a new tool here, also update the documentation for the new tool in the README.md
]; ];
@ -121,20 +145,6 @@ export function getPackageManagerList() {
return `${tools.join(", ")}, and ${lastTool} commands`; return `${tools.join(", ")}, and ${lastTool} commands`;
} }
/**
* @returns {string}
*/
export function getShimsDir() {
return path.join(os.homedir(), ".safe-chain", "shims");
}
/**
* @returns {string}
*/
export function getScriptsDir() {
return path.join(os.homedir(), ".safe-chain", "scripts");
}
/** /**
* @param {string} executableName * @param {string} executableName
* *

View file

@ -1,6 +1,6 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test"; import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { tmpdir } from "node:os"; import { tmpdir, homedir } from "node:os";
import fs from "node:fs"; import fs from "node:fs";
import path from "path"; import path from "path";
@ -15,6 +15,7 @@ describe("removeLinesMatchingPatternTests", () => {
mock.module("node:os", { mock.module("node:os", {
namedExports: { namedExports: {
EOL: "\r\n", // Simulate Windows line endings EOL: "\r\n", // Simulate Windows line endings
homedir,
tmpdir: tmpdir, tmpdir: tmpdir,
platform: () => "linux", platform: () => "linux",
}, },
@ -182,3 +183,30 @@ describe("removeLinesMatchingPatternTests", () => {
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines"); assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines");
}); });
}); });
describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => {
it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => {
const { getSafeChainBaseDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain"));
});
it("getBinDir returns ~/.safe-chain/bin by default", async () => {
const { getBinDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin"));
});
it("getShimsDir returns ~/.safe-chain/shims by default", async () => {
const { getShimsDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims"));
});
it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => {
const { getScriptsDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts"));
});
it("getCertsDir returns ~/.safe-chain/certs by default", async () => {
const { getCertsDir } = await import("../config/safeChainDir.js");
assert.strictEqual(getCertsDir(), path.join(homedir(), ".safe-chain", "certs"));
});
});

View file

@ -4,13 +4,31 @@
# Function to remove shim from PATH (POSIX-compliant) # Function to remove shim from PATH (POSIX-compliant)
remove_shim_from_path() { remove_shim_from_path() {
echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" _safe_chain_phys=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P)
if [ -z "$_safe_chain_phys" ]; then
echo "$PATH"
return
fi
_path=$(echo "$PATH" | sed "s|${_safe_chain_phys}:||g")
# Also remove via dirname of $0 directly — on macOS /tmp is a symlink to /private/tmp,
# so pwd -P resolves to /private/tmp/… but PATH may still contain /tmp/….
_dir=$(dirname -- "$0")
case "$_dir" in
/*) [ "$_dir" != "$_safe_chain_phys" ] && _path=$(echo "$_path" | sed "s|${_dir}:||g") ;;
esac
echo "$_path"
} }
if command -v safe-chain >/dev/null 2>&1; then 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}} "$@" PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@"
else else
# safe-chain is not reachable — warn the user so they know protection is inactive
printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. {{PACKAGE_MANAGER}} will run without it.\n" >&2
# Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory) # Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory)
original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}}) original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}})
if [ -n "$original_cmd" ]; then if [ -n "$original_cmd" ]; then

View file

@ -3,7 +3,8 @@ REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain
REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments
REM Remove shim directory from PATH to prevent infinite loops REM Remove shim directory from PATH to prevent infinite loops
set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" set "SHIM_DIR=%~dp0"
if "%SHIM_DIR:~-1%"=="\" set "SHIM_DIR=%SHIM_DIR:~0,-1%"
call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%"
REM Check if aikido command is available with clean PATH REM Check if aikido command is available with clean PATH

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

@ -1,24 +1,14 @@
import chalk from "chalk"; import chalk from "chalk";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js"; import { getPackageManagerList, knownAikidoTools } from "./helpers.js";
import {
getShimsDir,
getBinDir,
getPathWrapperTemplatePath,
} from "../config/safeChainDir.js";
import fs from "fs"; import fs from "fs";
import os from "os"; import os from "os";
import path from "path"; import path from "path";
import { fileURLToPath } from "url";
/** @type {string} */
// This checks the current file's dirname in a way that's compatible with:
// - Modulejs (import.meta.url)
// - ES modules (__dirname)
// This is needed because safe-chain's npm package is built using ES modules,
// but building the binaries requires commonjs.
let dirname;
if (import.meta.url) {
const filename = fileURLToPath(import.meta.url);
dirname = path.dirname(filename);
} else {
dirname = __dirname;
}
/** /**
* Loops over the detected shells and calls the setup function for each. * Loops over the detected shells and calls the setup function for each.
@ -31,7 +21,7 @@ export async function setupCi() {
ui.emptyLine(); ui.emptyLine();
const shimsDir = getShimsDir(); const shimsDir = getShimsDir();
const binDir = path.join(os.homedir(), ".safe-chain", "bin"); const binDir = getBinDir();
// Create the shims directory if it doesn't exist // Create the shims directory if it doesn't exist
if (!fs.existsSync(shimsDir)) { if (!fs.existsSync(shimsDir)) {
fs.mkdirSync(shimsDir, { recursive: true }); fs.mkdirSync(shimsDir, { recursive: true });
@ -50,12 +40,7 @@ export async function setupCi() {
*/ */
function createUnixShims(shimsDir) { function createUnixShims(shimsDir) {
// Read the template file // Read the template file
const templatePath = path.resolve( const templatePath = getPathWrapperTemplatePath(import.meta.url, "unix-wrapper.template.sh");
dirname,
"path-wrappers",
"templates",
"unix-wrapper.template.sh"
);
if (!fs.existsSync(templatePath)) { if (!fs.existsSync(templatePath)) {
ui.writeError(`Template file not found: ${templatePath}`); ui.writeError(`Template file not found: ${templatePath}`);
@ -89,12 +74,7 @@ function createUnixShims(shimsDir) {
*/ */
function createWindowsShims(shimsDir) { function createWindowsShims(shimsDir) {
// Read the template file // Read the template file
const templatePath = path.resolve( const templatePath = getPathWrapperTemplatePath(import.meta.url, "windows-wrapper.template.cmd");
dirname,
"path-wrappers",
"templates",
"windows-wrapper.template.cmd"
);
if (!fs.existsSync(templatePath)) { if (!fs.existsSync(templatePath)) {
ui.writeError(`Windows template file not found: ${templatePath}`); ui.writeError(`Windows template file not found: ${templatePath}`);

View file

@ -22,12 +22,12 @@ describe("Setup CI shell integration", () => {
fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true }); fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"), path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"),
"#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\nexec {{AIKIDO_COMMAND}} \"$@\"\n", "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\n_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)\nexec {{AIKIDO_COMMAND}} \"$@\"\n",
"utf-8" "utf-8"
); );
fs.writeFileSync( fs.writeFileSync(
path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"), path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"),
"@echo off\nREM Template for {{PACKAGE_MANAGER}}\n{{AIKIDO_COMMAND}} %*\n", "@echo off\nset \"SHIM_DIR=%~dp0\"\n{{AIKIDO_COMMAND}} %*\n",
"utf-8" "utf-8"
); );
@ -50,7 +50,15 @@ describe("Setup CI shell integration", () => {
{ tool: "yarn", aikidoCommand: "aikido-yarn" }, { tool: "yarn", aikidoCommand: "aikido-yarn" },
], ],
getPackageManagerList: () => "npm, yarn", getPackageManagerList: () => "npm, yarn",
},
});
mock.module("../config/safeChainDir.js", {
namedExports: {
getShimsDir: () => mockShimsDir, getShimsDir: () => mockShimsDir,
getBinDir: () => path.join(mockHomeDir, ".safe-chain", "bin"),
getPathWrapperTemplatePath: (_moduleUrl, fileName) =>
path.join(mockTemplateDir, "path-wrappers", "templates", fileName),
}, },
}); });
@ -63,22 +71,6 @@ describe("Setup CI shell integration", () => {
}, },
}); });
// Mock path module to resolve templates correctly
mock.module("path", {
namedExports: {
join: path.join,
dirname: () => mockTemplateDir,
resolve: (...args) => path.resolve(mockTemplateDir, ...args.slice(1)),
},
});
// Mock fileURLToPath
mock.module("url", {
namedExports: {
fileURLToPath: () => path.join(mockTemplateDir, "setup-ci.js"),
},
});
// Import setupCi module after mocking // Import setupCi module after mocking
setupCi = (await import("./setup-ci.js")).setupCi; setupCi = (await import("./setup-ci.js")).setupCi;
}); });
@ -119,6 +111,10 @@ describe("Setup CI shell integration", () => {
const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); const npmShimContent = fs.readFileSync(npmShimPath, "utf-8");
assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm"); assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm");
assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang"); assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang");
assert.ok(
npmShimContent.includes("_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)"),
"npm shim should derive the shims directory from its own location",
);
}); });
it("should create Windows .cmd shims on win32 platform", async () => { it("should create Windows .cmd shims on win32 platform", async () => {
@ -142,6 +138,10 @@ describe("Setup CI shell integration", () => {
assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm"); assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm");
assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header"); assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header");
assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing"); assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing");
assert.ok(
npmShimContent.includes('set "SHIM_DIR=%~dp0"'),
"npm.cmd should derive the shims directory from its own location",
);
// Verify Unix shims were NOT created // Verify Unix shims were NOT created
const unixNpmShim = path.join(mockShimsDir, "npm"); const unixNpmShim = path.join(mockShimsDir, "npm");

View file

@ -1,28 +1,10 @@
import chalk from "chalk"; import chalk from "chalk";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js"; import { detectShells } from "./shellDetection.js";
import { import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
knownAikidoTools, import { getScriptsDir, getStartupScriptSourcePath } from "../config/safeChainDir.js";
getPackageManagerList,
getScriptsDir,
} from "./helpers.js";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { fileURLToPath } from "url";
/** @type {string} */
// This checks the current file's dirname in a way that's compatible with:
// - Modulejs (import.meta.url)
// - ES modules (__dirname)
// This is needed because safe-chain's npm package is built using ES modules,
// but building the binaries requires commonjs.
let dirname;
if (import.meta.url) {
const filename = fileURLToPath(import.meta.url);
dirname = path.dirname(filename);
} else {
dirname = __dirname;
}
/** /**
* Loops over the detected shells and calls the setup function for each. * Loops over the detected shells and calls the setup function for each.
@ -91,9 +73,7 @@ async function setupShell(shell) {
); );
} else { } else {
ui.writeError( ui.writeError(
`${chalk.bold("- " + shell.name + ":")} ${chalk.red( `${chalk.bold("- " + shell.name + ":")} ${chalk.red("Setup failed")}`,
"Setup failed",
)}. Please check your ${shell.name} configuration.`,
); );
if (error) { if (error) {
let message = ` Error: ${error.message}`; let message = ` Error: ${error.message}`;
@ -102,6 +82,12 @@ async function setupShell(shell) {
} }
ui.writeError(message); ui.writeError(message);
} }
ui.emptyLine();
ui.writeInformation(` ${chalk.bold("To set up manually:")}`);
for (const instruction of shell.getManualSetupInstructions()) {
ui.writeInformation(` ${instruction}`);
}
ui.emptyLine();
} }
return success; return success;
@ -118,8 +104,7 @@ function copyStartupFiles() {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
} }
// Use absolute path for source const sourcePath = getStartupScriptSourcePath(import.meta.url, file);
const sourcePath = path.join(dirname, "startup-scripts", file);
fs.copyFileSync(sourcePath, targetPath); fs.copyFileSync(sourcePath, targetPath);
} }
} }

View file

@ -11,6 +11,8 @@ import { ui } from "../environment/userInteraction.js";
* @property {() => boolean} isInstalled * @property {() => boolean} isInstalled
* @property {(tools: import("./helpers.js").AikidoTool[]) => boolean|Promise<boolean>} setup * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean|Promise<boolean>} setup
* @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} teardown * @property {(tools: import("./helpers.js").AikidoTool[]) => boolean} teardown
* @property {() => string[]} getManualSetupInstructions
* @property {() => string[]} getManualTeardownInstructions
*/ */
/** /**

View file

@ -1,4 +1,7 @@
set -gx PATH $PATH $HOME/.safe-chain/bin set -l safe_chain_script (status filename)
set -l safe_chain_scripts_dir (dirname $safe_chain_script)
set -l safe_chain_base (dirname $safe_chain_scripts_dir)
set -gx PATH $PATH $safe_chain_base/bin
function npx function npx
wrapSafeChainCommand "npx" $argv wrapSafeChainCommand "npx" $argv
@ -16,6 +19,14 @@ function pnpx
wrapSafeChainCommand "pnpx" $argv wrapSafeChainCommand "pnpx" $argv
end end
function rush
wrapSafeChainCommand "rush" $argv
end
function rushx
wrapSafeChainCommand "rushx" $argv
end
function bun function bun
wrapSafeChainCommand "bun" $argv wrapSafeChainCommand "bun" $argv
end end
@ -51,6 +62,10 @@ function uv
wrapSafeChainCommand "uv" $argv wrapSafeChainCommand "uv" $argv
end end
function uvx
wrapSafeChainCommand "uvx" $argv
end
function poetry function poetry
wrapSafeChainCommand "poetry" $argv wrapSafeChainCommand "poetry" $argv
end end
@ -69,6 +84,10 @@ function pipx
wrapSafeChainCommand "pipx" $argv wrapSafeChainCommand "pipx" $argv
end end
function pdm
wrapSafeChainCommand "pdm" $argv
end
function printSafeChainWarning function printSafeChainWarning
set original_cmd $argv[1] set original_cmd $argv[1]
@ -105,8 +124,10 @@ function wrapSafeChainCommand
end end
if type -q safe-chain if type -q safe-chain
# If the safe-chain command is available, just run it with the provided arguments # If the safe-chain command is available, just run it with the provided arguments.
safe-chain $original_cmd $cmd_args # 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 else
# If the safe-chain command is not available, print a warning and run the original command # If the safe-chain command is not available, print a warning and run the original command
printSafeChainWarning $original_cmd printSafeChainWarning $original_cmd

Some files were not shown because too many files have changed in this diff Show more