Compare commits

...

1035 commits
1.1.1 ... main

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
bitterpanda
d83e271d3e
Merge pull request #346 from AikidoSec/min-package-age-48-hours
Increase default min package age to 48 hours
2026-03-25 09:01:19 -07:00
Sander Declerck
d113ca3061
Increase default min package age to 48 hours 2026-03-25 16:19:15 +01:00
Sander Declerck
d29edc4c36
Merge pull request #341 from AikidoSec/fix-release-build
Fix release build - use runner with static ip for releases
2026-03-25 13:24:35 +01:00
Sander Declerck
e9f941e3d0
Use runner with static ip for releases 2026-03-25 09:53:42 +01:00
willem-delbare
d5744fb51e
Merge pull request #339 from AikidoSec/fix/AIK-10759-AIK-11103-sast-20383130-ttpn
[Aikido] AI Fix for Template Injection in GitHub Workflows Action
2026-03-23 15:04:53 -07:00
aikido-autofix[bot]
cc5a7d9a0b
fix(security): autofix Template Injection in GitHub Workflows Action 2026-03-23 21:57:05 +00: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
bitterpanda
5864b09bde
Merge pull request #337 from AikidoSec/remove-dotaikido-in-uninstall
Remove the .aikido directory when uninstalling
2026-03-19 18:39:08 +01:00
bitterpanda
a7a94d9211
Merge pull request #338 from AikidoSec/cleanup-cert-bundle
Cleanup generated cert bundles
2026-03-19 18:38:18 +01:00
Sander Declerck
cfaa8e45ad
Move config file to .safe-chain path. 2026-03-19 16:10:32 +01:00
Sander Declerck
ffbdedc7cd
Don't delete .aikido folder 2026-03-19 15:51:20 +01:00
Sander Declerck
d9e6b89918
Undo dot in comment 2026-03-19 15:42:09 +01:00
Sander Declerck
47377711b8
Write log when certbundle could not be deleted 2026-03-19 11:11:34 +01:00
Sander Declerck
527e3cd70a
Cleanup generated cert bundles 2026-03-19 11:08:38 +01:00
Sander Declerck
9494b5aae8
Remove the .aikido directory when uninstalling 2026-03-19 09:13:45 +01:00
Sander Declerck
9749990dcc
Merge pull request #326 from liuxiaopai-ai/fix/issue-309-command-execution-error
fix(cli): surface missing runtime command errors
2026-03-19 08:53:02 +01:00
Sander Declerck
7eb93f6323
Merge pull request #333 from AikidoSec/endpoint-install-script
Implement Aikido Endpoint installation script
2026-03-13 14:49:44 +01:00
Sander Declerck
b3e5726a83
Add new scripts to release 2026-03-13 14:30:29 +01:00
Sander Declerck
8eabdd17ba
Verify token format 2026-03-13 14:19:25 +01:00
Sander Declerck
af90b20f12
Add uninstall scripts 2026-03-13 14:09:50 +01:00
Sander Declerck
4bf27ac2db
Add windows install script 2026-03-13 13:36:56 +01:00
Sander Declerck
7c5692f700
Update endpoint to 1.2.5 2026-03-13 13:30:13 +01:00
Sander Declerck
5dfccaac9d
Update install url to arm64 pkg 2026-03-13 12:27:21 +01:00
Sander Declerck
b3d81d2f43
Don't prompt for token 2026-03-13 11:58:44 +01:00
Sander Declerck
9de74886b6
Implement Aikido Endpoint installation script 2026-03-13 11:53:31 +01:00
bitterpanda
6c6ce796d9
Merge pull request #297 from AikidoSec/use-bash-for-windows-ci-safe-chain-setup
Use bash for setting up safe-chain in CI
2026-03-02 12:35:52 +01:00
Sander Declerck
c87a8ad7d9
Use latest version 2026-03-02 11:49:39 +01:00
root
ce05e82885 fix(cli): remove unused ui imports after error-helper refactor 2026-02-27 01:36:42 +08:00
root
62e262785f fix(cli): surface package manager command execution failures 2026-02-27 01:09:45 +08:00
bitterpanda
1177d38087
Merge pull request #321 from AikidoSec/remove-ultimate-commands
Remove ultimate commands (not ready yet)
2026-02-17 13:12:43 +01:00
Sander Declerck
e6a58ef5ae
Remove ultimate from list of available commands 2026-02-17 12:36:32 +01:00
Sander Declerck
688f017d3c
Fix linting issues 2026-02-17 12:35:16 +01:00
Sander Declerck
dc09d871ed
Remove ultimate commands (not ready yet) 2026-02-17 12:33:25 +01:00
Sander Declerck
86ae23332e
Merge pull request #315 from AikidoSec/gitlab-ci-cd
Document CI/CD for GitLab
2026-02-05 14:11:20 +01:00
bitterpanda
5796f12fa8
Merge pull request #316 from AikidoSec/powershell-executionpolicy-check-beta
Powershell: check  if the executionpolicy allows to run safe-chain
2026-02-05 13:12:16 +01:00
Sander Declerck
87c5eddc9e
Write warning when getting executionpolicy fails 2026-02-05 11:52:06 +01:00
Sander Declerck
8ea4463ac5
Update troubleshooting link 2026-02-05 11:38:28 +01:00
bitterpanda
32eb81337e
Merge pull request #317 from AikidoSec/kidk-logging
Remove duplicate verbose logging information from troubleshooting
2026-02-05 11:35:40 +01:00
Sander Declerck
446f45cc28
Add link to help 2026-02-05 11:35:30 +01:00
Samuel
cab1e11e95
Remove duplicate verbose logging information from troubleshooting
Removed section on enabling verbose logging for diagnostics.
2026-02-05 11:33:37 +01:00
Sander Declerck
149a28e0dc
Improve comments 2026-02-05 11:20:14 +01:00
Sander Declerck
03d67d92be
Change teardown order 2026-02-05 11:09:15 +01:00
Sander Declerck
369167e005
Error message indentation fix 2026-02-05 11:08:04 +01:00
Sander Declerck
bab128ab26
Undo install script changes 2026-02-05 11:03:49 +01:00
Sander Declerck
f1e5e7bab2
Improve error message 2026-02-05 11:01:56 +01:00
Sander Declerck
0dfa151b02
Fix linting 2026-02-05 10:45:45 +01:00
Sander Declerck
13f2ae6e22
Fix PSModulePath 2026-02-05 10:45:13 +01:00
Sander Declerck
aa461b27c3
Use safeSpawn 2026-02-05 10:24:28 +01:00
Sander Declerck
3e90c0abd1
Import module for execution policy 2026-02-05 10:12:43 +01:00
Sander Declerck
ad32a8d9be
Run command for execution policy with -Command 2026-02-05 10:05:26 +01:00
Sander Declerck
ff16530314
Fix linting 2026-02-05 09:52:18 +01:00
Sander Declerck
e9799e283f
Check powershell execution policy in setup function 2026-02-05 09:49:36 +01:00
Sander Declerck
c765438e63
Powershell: check if the executionpolicy allow to run safe-chain 2026-02-04 16:30:29 +01:00
Sander Declerck
90eba0a0b6
Document CI/CD for GitLab 2026-02-04 14:04:46 +01:00
bitterpanda
611fe8007f
Merge pull request #312 from AikidoSec/ultimate-verify-number-of-arguments
Verify the number of arguments for ultimate commands
2026-02-03 20:21:37 +01:00
Sander Declerck
e9ed6063c3
Verify the number of arguments for ultimate commands 2026-02-02 15:28:44 +01:00
Sander Declerck
b96bbc91a4
Merge pull request #311 from AikidoSec/add-log-cmd
add safe-chain ultimate logs & collect-logs
2026-01-30 16:33:51 +01:00
BitterPanda
768de61401 install deps in safe-chain/package.json 2026-01-30 15:48:39 +01:00
BitterPanda
90a44d999a Revert "install archiver"
This reverts commit 4c29eb3549.
2026-01-30 15:47:49 +01:00
BitterPanda
ceaf69c27d Revert "add 'archiver' types"
This reverts commit ef05762635.
2026-01-30 15:47:41 +01:00
bitterpanda
7e35d8df56 troubleshooting-export: update description 2026-01-30 15:19:56 +01:00
bitterpanda
adcf609066 rename to troubleshooting-* 2026-01-30 15:16:39 +01:00
bitterpanda
38b7c51985 Cleanup linting errors 2026-01-30 14:44:26 +01:00
bitterpanda
ef05762635 add 'archiver' types 2026-01-30 14:42:43 +01:00
BitterPanda
adc384dd78 use path.resolve to print full file 2026-01-30 14:26:26 +01:00
BitterPanda
5ab5fee130 add docs & collect-logs to safe-chain bin 2026-01-30 14:25:20 +01:00
BitterPanda
460be68cd3 create an export async collectLogs 2026-01-30 14:22:47 +01:00
BitterPanda
4c29eb3549 install archiver 2026-01-30 14:22:21 +01:00
BitterPanda
dfac510c15 add safe-chain ultimate logs 2026-01-30 14:15:00 +01:00
bitterpanda
337d914124
Merge pull request #310 from AikidoSec/powershell-executionpolicy-troubleshooting
Add troubleshooting steps for powershell when executionpolicy doens't allow to run code
2026-01-30 13:59:15 +01:00
Sander Declerck
632b3948e3
Add troubleshooting steps for powershell when executionpolicy doens't allow to run code 2026-01-30 13:57:39 +01:00
Sander Declerck
8e67f2edcd
Merge pull request #308 from AikidoSec/bump-agent-version-1-0-0
Bump agent version to v1.0.0
2026-01-29 17:28:22 +01:00
Sander Declerck
4ccdd9fef6
Bump agent version to v1.0.0 2026-01-29 17:06:39 +01:00
Sander Declerck
ca101270cc
Merge pull request #304 from AikidoSec/ultimate-uninstaller
Add uninstallation process for ultimate
2026-01-28 16:03:18 +01:00
Sander Declerck
e36b7e80b4
Fix uninstall script 2026-01-28 15:42:15 +01:00
Sander Declerck
aa6553716d
Mac: use uninstaller script 2026-01-28 15:33:45 +01:00
Sander Declerck
57c090c3a7
Rename output 2026-01-28 07:54:35 +01:00
Sander Declerck
a3ab80b8b4
PR comment: extract requireRootPrivileges / requireAdminPrivileges into separate function 2026-01-28 07:53:39 +01:00
Sander Declerck
7218d778cf
Update commands for ultimate 2026-01-27 13:06:17 +01:00
Sander Declerck
a016483057
Remove duplicate "Stopping running service" log 2026-01-27 12:57:40 +01:00
Sander Declerck
12caa6d1d4
Verify download links in a test 2026-01-27 12:44:47 +01:00
Sander Declerck
af4bbb10fc
Bump safe-chain-internals version 2026-01-27 12:41:11 +01:00
Sander Declerck
1058630dd1
Add uninstallation process for ultimate 2026-01-27 11:29:19 +01:00
bitterpanda
8c8a4481ee
Merge pull request #303 from AikidoSec/fix-spaces-in-rc-file 2026-01-27 11:26:00 +01:00
Sander Declerck
309d7df050
Don't insert empty line in rc file when it already ends with an empty line 2026-01-27 07:42:36 +01:00
bitterpanda
8e966b0609
Merge pull request #295 from AikidoSec/ultimate-installer
Add safe-chain ultimate installer
2026-01-22 14:13:55 +01:00
Sander Declerck
f825f84faa
Add message about the certificate popup 2026-01-22 12:51:25 +01:00
Sander Declerck
1e74b8af8f
Merge branch 'main' into ultimate-installer 2026-01-22 12:37:08 +01:00
bitterpanda
b0d0110b81
Merge pull request #301 from AikidoSec/fix-mitm-tests
Fix tests for mitm registryproxy
2026-01-22 12:35:09 +01:00
Sander Declerck
c02d0785fa
Fix tests for mitm registryproxy 2026-01-22 11:58:52 +01:00
Sander Declerck
09730a0775
Update application names on Windows 2026-01-22 09:23:17 +01:00
Sander Declerck
b2d94aaa16
Fix download links 2026-01-22 09:18:23 +01:00
Sander Declerck
b7a5adf670
Fix linting 2026-01-22 09:13:43 +01:00
Sander Declerck
9cde77a408
PR comments 2026-01-22 08:20:45 +01:00
Sander Declerck
b9aade2da4
Remove unused variable 2026-01-21 09:18:26 +01:00
Sander Declerck
d4c496d60d
Add mac os installation 2026-01-21 09:14:44 +01:00
Sander Declerck
a7e21bbfe2
Update download urls 2026-01-21 07:58:06 +01:00
Sander Declerck
0d8b919831
Use bash for setting up safe-chain in CI 2026-01-20 13:34:22 +01:00
bitterpanda
4b07619769
Merge pull request #296 from AikidoSec/windows-install-script-in-git-bash-beta
Support Windows in install-safe-shain.sh (git bash, cygwin, ...)
2026-01-20 13:30:14 +01:00
Sander Declerck
99cd416628
Support Windows in install-safe-shain.sh (git bash, cygwin, ...) 2026-01-20 12:53:18 +01:00
Sander Declerck
626bb0d2b9
Don't start the windows service - the msi already does this 2026-01-20 12:21:45 +01:00
Sander Declerck
7d55c5453b
Move os and arch detection to downloader, add checksum verification. 2026-01-20 09:12:00 +01:00
bitterpanda
3dad1c2516
Update packages/safe-chain/src/installation/installOnWindows.js 2026-01-19 19:01:28 +01:00
bitterpanda
9651e05f4b
Fix naming of SafeChain Agent 2026-01-19 18:59:37 +01:00
Sander Declerck
da6c022ef4
Add explaining comments for powershell scritps 2026-01-19 16:25:50 +01:00
Sander Declerck
c200ea56cf
Cleanup debug logging 2026-01-19 16:23:59 +01:00
Sander Declerck
20fb949a23
Fix uninstall 2026-01-19 16:17:34 +01:00
Sander Declerck
4a7629a174
Use execSync to execute powershell command 2026-01-19 16:11:51 +01:00
Sander Declerck
211f877384
Write stdout stderr 2026-01-19 16:03:51 +01:00
Sander Declerck
4ebbbca432
Temporarily disable cleanup 2026-01-19 15:58:11 +01:00
Sander Declerck
eb00fe6f3d
Write error output 2026-01-19 15:54:02 +01:00
Sander Declerck
86e6007733
Improve error handling 2026-01-19 15:45:32 +01:00
Sander Declerck
4a90bd2621
Code quality: use early return 2026-01-19 15:34:16 +01:00
Sander Declerck
8b189443b7
Use safeSpawn instead of execSync 2026-01-19 15:31:41 +01:00
Sander Declerck
9b61a325fa
Log when installer file cleanup failed 2026-01-19 15:24:49 +01:00
Sander Declerck
471ef28210
Handle code quality comments 2026-01-19 15:22:24 +01:00
Sander Declerck
079e4893b1
Move download name construction to os installer function 2026-01-19 14:53:33 +01:00
Sander Declerck
fd559cfc63
Restructure code into separate files 2026-01-19 14:46:04 +01:00
Sander Declerck
0e7cce750d
Improve output 2026-01-19 14:30:09 +01:00
Sander Declerck
2784dfd34e
Check if the agents service is running before starting it 2026-01-19 14:23:15 +01:00
Sander Declerck
3958fcfcef
Parse cli args in ultimate installation 2026-01-19 14:06:43 +01:00
Sander Declerck
673783ceab
Uninstall safe-chain agent if it's there, before re-installing 2026-01-19 14:00:09 +01:00
Sander Declerck
c4941e25ed
Fix linting 2026-01-19 13:55:41 +01:00
Sander Declerck
4851e582f6
Improve updating existing agent install 2026-01-19 13:54:32 +01:00
Sander Declerck
6a3c7b938b
Overwrite the agent if it's already installed. 2026-01-19 13:48:33 +01:00
Sander Declerck
2c0245b020
Start and stop safe-chain agent's Windows service. 2026-01-19 13:28:16 +01:00
Sander Declerck
879b37e164
Add ultimate installer for Windows 2026-01-19 12:47:57 +01:00
Reinier Criel
f358709ab2
Merge pull request #282 from uriel-ecosia/command-not-found
Propagate command-not-found errors when invoking wrapped commands
2026-01-15 18:38:30 +01:00
Sander Declerck
05f7c8f877
Merge pull request #293 from AikidoSec/min-package-age-exclusion
Min package age exclusion
2026-01-15 16:08:33 +01:00
Sander Declerck
6c814ff82f
Only allow wildcards for scoped packages (@scope/*) 2026-01-15 15:13:00 +01:00
Reinier Criel
b6b880d21a
Merge pull request #287 from AikidoSec/bug/win32-command-parsing-beta
Fix double dash argument forwarding on Win32 PowerShell
2026-01-14 20:09:56 +01:00
Sander Declerck
884cb6e026
Allow trailing * for wildcard matching 2026-01-14 17:51:41 +01:00
Sander Declerck
6815b62019
Allow to exclude packages from the minimum package age 2026-01-14 17:41:23 +01:00
bitterpanda
5898fc851a
Merge pull request #292 from AikidoSec/retry-malware-db-download
Retry downloading the malware database 3 times
2026-01-14 15:58:39 +01:00
bitterpanda
9d55afbf85
Update packages/safe-chain/src/api/aikido.js 2026-01-14 15:33:09 +01:00
Sander Declerck
6f4eaf5234
Don't swallow error on retry 2026-01-14 15:31:37 +01:00
Sander Declerck
a5d545f29b
Handle pr comments 2026-01-14 14:55:11 +01:00
Sander Declerck
8d2655a4bf
Add tests for malware db retry 2026-01-14 14:41:06 +01:00
Sander Declerck
d83a381231
Retry downloading the malware database 3 times 2026-01-14 14:02:27 +01:00
Reinier Criel
045fc1519b
Merge pull request #288 from slootjes/patch-1
Add Bitbucket Pipelines example
2026-01-13 21:16:01 +01:00
Reinier Criel
b592da7431
Merge pull request #290 from AikidoSec/feature/logging-for-min-package-age
Include package name in logging when minimum package age is not met
2026-01-13 19:52:14 +01:00
bitterpanda
c38f1bcb3e
Update packages/safe-chain/src/registryProxy/interceptors/npm/modifyNpmInfo.js 2026-01-13 19:33:00 +01:00
Reinier Criel
f678ff8dd1 Include package name in logging when minimum package age is not met 2026-01-13 10:09:59 -08:00
Robert Slootjes
b25d405972
Add Bitbucket Pipelines example 2026-01-13 08:19:10 +01:00
Reinier Criel
340e9a90a5 Remove comment 2026-01-12 15:13:34 -08:00
Reinier Criel
9a902af917 Fix some logic 2026-01-12 15:12:19 -08:00
Reinier Criel
19652c49c9 Attempted fix for powershell swallowing '--' 2026-01-12 14:53:23 -08:00
Sander Declerck
31b5f73197
Merge pull request #285 from AikidoSec/logging-as-env-variable
Allow to configure loglevel through an env variable
2026-01-12 12:41:13 +01:00
Sander Declerck
595f269f62
Add comment about backwards compat. 2026-01-12 11:20:25 +01:00
Sander Declerck
20994c1834
Document to configure loglevel through env variables. 2026-01-12 11:01:54 +01:00
Sander Declerck
3573ef2bc5
Allow to configure loglevel through an env variable 2026-01-12 10:50:06 +01:00
Sander Declerck
6d2d943e18
Merge pull request #284 from AikidoSec/troubleshooting-packagemanager-cache
Add a section about troubleshooting when the package is already in the cache
2026-01-09 09:28:39 +01:00
Uriel Corfa
0ce0a87557
Add the same handler for fish 2026-01-08 10:01:13 +01:00
Uriel Corfa
4e894dd0fd
init-posix: preserve arguments when exec'ing the original_cmd 2026-01-08 09:56:59 +01:00
Sander Declerck
6a70898e7b
Remove "optional" from "Clean local installation artifacts" 2026-01-08 08:01:48 +01:00
Sander Declerck
59f8b55bda
Add a section about troubleshooting when the package is already in the cache 2026-01-08 08:00:26 +01:00
Uriel Corfa
3bfca9e296
Propagate command-not-found errors when invoking wrapped commands
Before this change, if a package manager was not installed, safe-chain still
sets the function and when invoked, the wrapper will invoke safe-chain, which
will exit with error code 127 when it fails to invoke the wrapped command. As an
example (with a shell prompt that shows $? when non-zero):

```
$ type -f pip
bash: type: pip: not found
1$ pip
127$
```

With this patch, the wrapper first checks for the existence of the wrapped
command (ignoring functions), and if no such command exists, it instructs the
shell to invoke it anyway. This results in the shell failing to find the
command, and reporting an error as if the wrapper function wasn't there:

```
$ source init-posix.sh
$ type -f pip
bash: type: pip: not found
1$ pip
Command 'pip' not found, but can be installed with:
sudo apt install python3-pip
127$
```
2026-01-07 17:18:48 +01:00
Sander Declerck
4a63f976ae
Merge pull request #280 from AikidoSec/fix-broken-compatibility-in-install-beta
Fix broken compatibility in install
2026-01-07 14:40:36 +01:00
Sander Declerck
43eda4fadf
Add deprecation message to powershell version as well 2026-01-07 14:20:16 +01:00
Sander Declerck
6820e1e76c
Fix broken compatibility in install 2026-01-07 14:09:18 +01:00
Sander Declerck
094d1416ca
Merge pull request #272 from graemechapman/patch-1
fix: Allow running commands if safe-chain npm package is not installed
2026-01-07 12:03:19 +01:00
Sander Declerck
b215474271
Merge pull request #273 from AikidoSec/docker-standalone-exec
Safe-chain standalone binaries for docker
2026-01-07 12:01:33 +01:00
Sander Declerck
b2a5336556
Use latest build of safe-chain in CI again 2026-01-07 11:39:22 +01:00
Sander Declerck
7a4b7057bc
Test on gh actions 2026-01-07 09:40:40 +01:00
Sander Declerck
8fc3727b88
Merge branch 'docker-standalone-exec' into docker-standalone-exec-beta 2026-01-07 08:55:34 +01:00
Sander Declerck
b19d67f853
Add linuxstatic artifact to release 2026-01-07 08:55:20 +01:00
Sander Declerck
17d567d0bb
Merge branch 'docker-standalone-exec' into docker-standalone-exec-beta 2026-01-07 08:49:18 +01:00
Sander Declerck
ffaf7b60b6
Merge branch 'main' into docker-standalone-exec 2026-01-07 08:48:30 +01:00
bitterpanda
0a7b096abf
Merge pull request #277 from AikidoSec/add-troubleshooting-docs
Add troubleshooting docs
2026-01-06 16:07:51 +01:00
Sander Declerck
504b3ca596
Update Conflicting Installations note 2026-01-06 16:04:15 +01:00
Sander Declerck
e8f993623b
Add troubleshooting docs 2026-01-06 15:48:15 +01:00
bitterpanda
5ebbf5c6b2
Merge pull request #276 from AikidoSec/cleanup-nvm-in-install-script-beta 2026-01-06 13:06:40 +01:00
Sander Declerck
1f4e50df9d
Checkout code in set version 2026-01-06 11:51:01 +01:00
Sander Declerck
66c1da0f1e
Rework release workflow (split npm and github release), and skip npm publish for prereleases 2026-01-06 11:48:06 +01:00
Sander Declerck
4e098bcff7
Change order of removal for npm-based installations 2026-01-06 11:23:47 +01:00
Sander Declerck
4aca6ef86a
Restore publish script 2026-01-06 10:54:34 +01:00
Sander Declerck
d7d5bacd21
Remove warning from readme 2026-01-06 10:53:32 +01:00
Sander Declerck
5a28d6646f
Update comments 2026-01-06 10:53:24 +01:00
Sander Declerck
10a2407b32
Source nvm in script 2026-01-06 10:43:15 +01:00
Sander Declerck
6bbd3f5955
Add nvm detection to uninstall script 2026-01-06 10:35:10 +01:00
Sander Declerck
efe3b24ab9
Comment npm publish step 2026-01-06 10:07:40 +01:00
Sander Declerck
24230da4a7
Add nvm safe-chain uninstallation in install script 2026-01-06 10:05:52 +01:00
Sander Declerck
eb32da49aa
Add extra artifact for linuxstatic, change install script to use it. 2026-01-06 09:05:32 +01:00
Sander Declerck
50f20cc30d
Run tests with 0.0.1-docker-linux-exec-beta 2026-01-06 09:05:32 +01:00
Sander Declerck
ff4618602a
Add extra artifact for linuxstatic, change install script to use it. 2026-01-06 09:02:22 +01:00
Sander Declerck
d530b9a1de
Run tests with 0.0.1-docker-linux-exec-beta 2026-01-06 08:17:35 +01:00
Sander Declerck
52a096b739
Re-order steps 2026-01-05 15:47:31 +01:00
Sander Declerck
35ca2233f8
Use linuxstatic target for linux 2026-01-05 15:45:57 +01:00
Sander Declerck
40b8638ddd
Fix artifact name 2026-01-05 14:24:19 +01:00
Sander Declerck
a910851422
Build for linuxstatic and alpine 2026-01-05 14:15:28 +01:00
Sander Declerck
8bfbe1c77d
Merge pull request #232 from galargh/pip-custom-registries
feat: allow python custom registries configuration
2026-01-05 14:01:51 +01:00
Sander Declerck
74c57cd86a
Merge pull request #262 from AikidoSec/safe-chain-verify-command
Add command to verify safe-chain is intercepting the package managers commands
2026-01-05 09:10:05 +01:00
galargh
b23ba9d9c4 chore: update test parametrization 2026-01-02 10:39:15 +01:00
Graeme Chapman
c510d886a9
Simplify command execution in init-posix.sh 2025-12-31 10:57:08 +00:00
Graeme Chapman
a0e19818a0
fix: Allow running commands if safe-chain npm package is not installed 2025-12-31 10:18:58 +00:00
bitterpanda
acb4aa1a13
Merge pull request #271 from AikidoSec/feature/jenkins 2025-12-30 20:22:31 +01:00
Reinier Criel
bc4370348f Adapt per review 2025-12-30 11:19:00 -08:00
Reinier Criel
8d0dcd0068 Small fix 2025-12-30 10:11:25 -08:00
Reinier Criel
7bfbe1376b Jenkins CI pipeline 2025-12-30 09:22:03 -08:00
Sander Declerck
25221b5271
Merge pull request #264 from jassanw/proxy-use-connect-request-port
Fix proxy request to respect HTTPS port from CONNECT request
2025-12-24 11:34:22 +01:00
galargh
c53a7347e2 feat: allow python custom registries configuration through config file 2025-12-22 13:49:45 +01:00
galargh
39e2001d97 Merge remote-tracking branch 'origin/main' into pip-custom-registries 2025-12-22 13:27:04 +01:00
jassanw
3b6beb7f16 default to port 443 if port is null or empty 2025-12-19 18:49:58 -08:00
cherryace
bd19f477f7 Using port from req url when creating proxy request instead of hardcoded port 443 2025-12-19 17:57:33 -08:00
Sander Declerck
b571aad6a0
Add command to verify safe-chain is intercepting the package managers commands 2025-12-19 16:18:21 +01:00
Sander Declerck
53c59e35e9
Merge pull request #258 from thomasbecker/fix/connection-timeout-issue-228
fix: use true connection timeout instead of idle timeout
2025-12-19 11:05:53 +01:00
Sander Declerck
e88f3f9c7c
Merge pull request #260 from AikidoSec/demo-video
Add demo gif to readme again
2025-12-19 11:01:33 +01:00
Sander Declerck
120e12fd34
Merge pull request #259 from AikidoSec/configure-custom-npm-registries
Allow to configure custom/private npm registries
2025-12-19 10:42:51 +01:00
Sander Declerck
5fec230181
Also commit readme 2025-12-19 10:42:17 +01:00
Sander Declerck
1084abe179
Add demo gif to readme again 2025-12-19 10:38:05 +01:00
Reinier Criel
bbf5f8189b
Merge pull request #256 from AikidoSec/feature/pipx-2
Add PIPX support
2025-12-19 09:41:00 +01:00
Sander Declerck
9f93763b98
Handle code quality comments 2025-12-18 18:18:45 +01:00
Sander Declerck
deb0ad5428
Create a single emptyConfig object 2025-12-18 18:03:09 +01:00
Sander Declerck
e3aa2e15cb
Add npmjs.com to known registries too. 2025-12-18 17:59:15 +01:00
Sander Declerck
41cc24d1f5
Allow to configure custom/prinvate npm registries 2025-12-18 13:52:49 +01:00
Reinier Criel
287bd7a41f Remove redundant comment 2025-12-18 13:41:18 +01:00
Reinier Criel
6ce3791140 Fix check 2025-12-18 13:37:29 +01:00
Thomas Becker
878e549211 fix: use true connection timeout instead of idle timeout
socket.setTimeout() is an idle timeout in Node.js (node docs)[https://nodejs.org/api/net.html#socketsettimeouttimeout-callback]
- it fires after N ms of inactivity, not N ms after the connection attempt. This
caused false timeout errors after successful data transfers when connections
went idle for longer than the timeout period.

Replace with JS setTimeout() that:
- Fires N ms after connection attempt starts
- Gets cleared on successful connect
- Return 504 Gateway Timeout (more accurate than 502)

Also adds proper close event handlers for socket cleanup.

Fixes #228
2025-12-18 12:53:49 +01:00
Reinier Criel
28f34a8380 Fix env func 2025-12-18 12:09:28 +01:00
Reinier Criel
a1d348b768 Fix test 2025-12-18 11:45:43 +01:00
Reinier Criel
dbc7272fb4 Some cleanup 2025-12-18 10:43:27 +01:00
Reinier Criel
d2fc531c81 Fix tests and add command support 2025-12-18 10:33:31 +01:00
Reinier Criel
0925279521
Merge pull request #253 from AikidoSec/feature/circle-ci
Add Circle CI Pipeline Guidance
2025-12-18 09:20:42 +01:00
Sander Declerck
66abb29cf3
Merge pull request #254 from AikidoSec/use-new-install-script
Use new release script in GH workflows
2025-12-17 14:31:18 +01:00
Reinier Criel
b9de94f0f1 Merge branch 'main' into feature/pipx-2 2025-12-17 14:28:14 +01:00
Sander Declerck
2cb891b935
Use correct Windows install script 2025-12-17 14:12:39 +01:00
Sander Declerck
a1ec035d9c
Use Windows installation script 2025-12-17 14:09:45 +01:00
Sander Declerck
148eb21430
Use new release script in GH workflows 2025-12-17 14:07:58 +01:00
Reinier Criel
50ed2a9a7f Merge branch 'main' into feature/circle-ci 2025-12-17 14:02:07 +01:00
bitterpanda
fb618b4b22
Merge pull request #252 from AikidoSec/check-current-version-before-install
Check current safe-chain version in installation script
2025-12-17 13:54:46 +01:00
bitterpanda
7dd832dd9a
Merge pull request #249 from AikidoSec/fix-failing-download-in-install-script-beta
Add install script with hard-coded version to build output
2025-12-17 13:53:13 +01:00
Reinier Criel
8c929f65e2 Update README 2025-12-17 13:51:56 +01:00
Reinier Criel
5de43c1bf2 Some modifications 2025-12-17 13:26:14 +01:00
Reinier Criel
3c18ad76f7 Skeleton 2025-12-17 11:37:51 +01:00
Sander Declerck
0b38fcd74e
Use return instead of exit 2025-12-17 10:20:31 +01:00
Sander Declerck
2374c76192
Check current safe-chain version in installation script 2025-12-17 09:35:10 +01:00
Sander Declerck
e6cfa65ee2
Document release scripts 2025-12-16 16:09:57 +01:00
bitterpanda
9db8a2cc24
Merge pull request #250 from AikidoSec/bug/py-flag-warning
Emit deprecation warning when --include-python flag is used
2025-12-16 15:25:38 +01:00
Sander Declerck
aaa5a41af6
Replace version correctly 2025-12-16 15:19:50 +01:00
Reinier Criel
379cd20154 Fix linter issue 2025-12-16 15:05:03 +01:00
Sander Declerck
8b2ebdf49c
Add correct destination operand for cp uninstall scripts 2025-12-16 14:57:53 +01:00
Sander Declerck
dc14d5023f
Move files to release-artifacts dir 2025-12-16 14:53:35 +01:00
Reinier Criel
a47ea153da Simplify 2025-12-16 14:53:30 +01:00
Sander Declerck
2068ede045
Disable push to npm 2025-12-16 14:47:53 +01:00
Reinier Criel
037a83e1ff Print warning if deprecated --include-python flag is given 2025-12-16 14:47:53 +01:00
Sander Declerck
dddd41e891
Add correct scripts to the release 2025-12-16 14:35:16 +01:00
Sander Declerck
2c2159e512
Add install script with hard-coded version to build output 2025-12-16 14:34:24 +01:00
bitterpanda
6bb0cedf21
Merge pull request #247 from AikidoSec/standalone-binary-in-safe-chain-pipelines
Use the standalone binary in our own pipelines
2025-12-16 13:36:06 +01:00
Sander Declerck
b060cec580
Revert "Add safe-chain-test for verification"
This reverts commit 7b8a945875.
2025-12-16 13:35:41 +01:00
Sander Declerck
7b8a945875
Add safe-chain-test for verification 2025-12-16 13:34:14 +01:00
bitterpanda
3d7b1a7df5
Merge pull request #246 from AikidoSec/fix-powershell-install-script-path-separator
Fix path separator on Windows Powershell
2025-12-16 13:26:24 +01:00
Sander Declerck
316922e9a6
Merge branch 'main' into fix-powershell-install-script-path-separator 2025-12-16 13:06:57 +01:00
Reinier Criel
6beb962282
Merge pull request #244 from AikidoSec/feature/remove-pypi-flag
Remove Python feature flag
2025-12-16 04:03:55 -08:00
Sander Declerck
5e28190d87
Split up setup step for Windows runner 2025-12-16 13:01:04 +01:00
Sander Declerck
4be1f7900d
Use the standalone binary in our own pipelines 2025-12-16 12:56:03 +01:00
Reinier Criel
b0faf9d48d Merge branch 'main' into feature/remove-pypi-flag 2025-12-16 09:05:10 +01:00
Sander Declerck
ba1eaf4afa
Merge pull request #241 from AikidoSec/disable-mac-unit-tests
Remove mac unit test runner
2025-12-15 19:19:49 +01:00
Reinier Criel
eefcb5a2aa Another adaptation in README 2025-12-15 18:54:54 +01:00
Sander Declerck
eb59e98785
Fix path separator on Windows Powershell 2025-12-15 17:50:38 +01:00
Sander Declerck
51bcdaca47
Merge pull request #245 from AikidoSec/fix-release-build
Fix build: install packages before setting the version
2025-12-15 16:52:36 +01:00
Sander Declerck
7b2e8eef46
Fix build: install packages before setting the version 2025-12-15 16:33:48 +01:00
Reinier Criel
a99762fc28 Some more doc updates 2025-12-15 16:14:48 +01:00
Reinier Criel
53e47581d4 Remove unneeded comment 2025-12-15 15:59:24 +01:00
Reinier Criel
c07abe966b Fix setup-ci 2025-12-15 15:55:41 +01:00
Reinier Criel
523ce0b6ee Fix issue with flag 2025-12-15 15:08:28 +01:00
Reinier Criel
7e460e50e1 Skeleton 2025-12-15 15:06:00 +01:00
Reinier Criel
dc6fcb9761 Skeleton 2025-12-15 14:42:58 +01:00
Sander Declerck
917bc66fb0
Merge branch 'main' into disable-mac-unit-tests 2025-12-15 10:51:58 +01:00
Reinier Criel
dc25345b7c
Some tweaks 2025-12-15 10:50:52 +01:00
Reinier Criel
77408f90b6
Fix flag 2025-12-15 10:50:52 +01:00
Reinier Criel
cba1fc36af
Adapt DockerFile 2025-12-15 10:50:52 +01:00
Reinier Criel
0d1283a0fc
Pipe output for better logging 2025-12-15 10:50:52 +01:00
Reinier Criel
fce81d8210
Better logging for e2e tests + allow buffering of logs 2025-12-15 10:50:52 +01:00
Sander Declerck
9fe6dccfca
Fix $env:USERPROFILE in pwsh script for unix 2025-12-15 10:50:51 +01:00
Sander Declerck
bd017d02e0
PR comments: handle unix on pwsh, update readme, rename variable in unix script 2025-12-15 10:50:51 +01:00
Sander Declerck
67d91c171a
Add uninstall scripts 2025-12-15 10:50:51 +01:00
Sander Declerck
8d5e8cc58f
Add tests for: not shortcircuiting timeout on imds endpoint. 2025-12-15 10:50:51 +01:00
Sander Declerck
11bd9b3c19
Only timeout for imds endpoints 2025-12-15 10:50:51 +01:00
Reinier Criel
7f1cbab717
Remove unnecessary change 2025-12-15 10:50:51 +01:00
Reinier Criel
c3244342e7
Fix test issue 2025-12-15 10:50:50 +01:00
Reinier Criel
d96cf7d14d
Fix linting issues 2025-12-15 10:50:50 +01:00
Reinier Criel
4210d00ac4
Fix tests 2025-12-15 10:50:50 +01:00
Reinier Criel
7b5a700655
Fix some issues 2025-12-15 10:50:50 +01:00
Reinier Criel
3de53e1f8a
Some fixes 2025-12-15 10:50:50 +01:00
Reinier Criel
f3b7847697
Add unit tests 2025-12-15 10:50:49 +01:00
Reinier Criel
ec22421bd9
Check input file 2025-12-15 10:50:49 +01:00
Reinier Criel
314001eb0c
Some improvements 2025-12-15 10:50:49 +01:00
Reinier Criel
02c30a2544
Combine NODE_EXTRA_CA_CERTS with Safe Chain's certificate bundle 2025-12-15 10:50:29 +01:00
Sander Declerck
09809d29bc
Refactor mocking in configFile.spec.js 2025-12-15 10:49:52 +01:00
Reinier Criel
fc5df6cd14
Merge pull request #238 from AikidoSec/feature/cleanup-shims
Cleanup shims at teardown
2025-12-15 01:36:19 -08:00
bitterpanda
6a00b623a8
Merge pull request #242 from AikidoSec/allow-0-min-package-age
Allow '0' for minimum package age setting.
2025-12-13 01:40:25 +01:00
Reinier Criel
f47cd7ebc0 Remove unused import 2025-12-12 12:07:06 -08:00
Reinier Criel
68180e5b44 Add more tests 2025-12-12 11:26:53 -08:00
Reinier Criel
a405a51706 Also remove script dir 2025-12-12 11:17:17 -08:00
Reinier Criel
7e88490bd1 Merge branch 'main' into feature/cleanup-shims 2025-12-12 08:03:12 -08:00
Sander Declerck
3d1e4b0489
Allow '0' for minimum package age setting. 2025-12-12 16:35:02 +01:00
Reinier Criel
5bab03991b
Merge pull request #236 from uriel-ecosia/uc-python-spawn
Fix `safe-chain python` exiting quietly
2025-12-12 07:26:18 -08:00
Sander Declerck
650dde4c84
Remove mac unit test runner 2025-12-12 15:51:48 +01:00
Uriel Corfa
cb9f3ee145
Do not rely on asynchronous import of child_process.
Importing child_process asynchronously causes loader errors when running the
binary dist:

$ ./dist/safe-chain python --safe-chain-logging=verbose
Safe-chain: Bypassing safe-chain for non-pip invocation: python
Failed to check for malicious packages: A dynamic import callback was not specified.
$

Relying on a regular import does not cause this issue. There is no obvious
reason for this import to be dynamic (in particular, there are no tests using
this to mock the spawn function), so let's simplify.
2025-12-12 09:09:52 +01:00
Uriel Corfa
db2c272aea
Add a unit test for shouldBypassSafeChain 2025-12-12 09:09:52 +01:00
Uriel Corfa
64d87ae1e1
Flush buffered logs before exiting 2025-12-12 09:09:50 +01:00
Reinier Criel
092df57695 Change order 2025-12-11 20:29:58 -08:00
Reinier Criel
2b0f8d9f0d Skeleton 2025-12-11 15:13:15 -08:00
bitterpanda
4623f3eff8
Merge pull request #237 from AikidoSec/feature/adjust-docker-logging
test(e2e): capture docker build output instead of ignoring it
2025-12-11 23:49:11 +01:00
Reinier Criel
df66863ae5 Some tweaks 2025-12-11 13:08:23 -08:00
Reinier Criel
a9a7a37f6a Fix flag 2025-12-11 10:57:18 -08:00
Reinier Criel
c385f9b371 Adapt DockerFile 2025-12-11 10:45:24 -08:00
Reinier Criel
2daddace31 Pipe output for better logging 2025-12-11 09:32:53 -08:00
Reinier Criel
7a9a6418a5 Better logging for e2e tests + allow buffering of logs 2025-12-11 09:06:50 -08:00
Reinier Criel
14bb6899d8
Merge pull request #223 from AikidoSec/feature/combine-certs
Combine cert with NODE_EXTRA_CA_CERTS if it already exists
2025-12-10 08:16:32 -08:00
Reinier Criel
bb44082d51 Merge branch 'main' into feature/combine-certs 2025-12-10 07:34:45 -08:00
Sander Declerck
2e212b950f
Merge pull request #231 from AikidoSec/uninstall-scripts
Add uninstall scripts
2025-12-10 14:16:29 +01:00
Sander Declerck
9c94fadfcc
Fix $env:USERPROFILE in pwsh script for unix 2025-12-10 13:55:08 +01:00
Sander Declerck
dace5f3845
PR comments: handle unix on pwsh, update readme, rename variable in unix script 2025-12-10 13:48:07 +01:00
galargh
833fa285aa feat: allow python custom registries configuration 2025-12-10 13:27:18 +01:00
Sander Declerck
1b5814ecc2
Add uninstall scripts 2025-12-10 09:54:15 +01:00
Reinier Criel
0b28cb8fdb Merge branch 'main' into feature/combine-certs 2025-12-09 14:31:05 -08:00
Sander Declerck
9444c7b4f6
Merge pull request #230 from AikidoSec/only-shortcircuit-timedout-imds-endpoints
Only short-circuit timed out imds endpoints
2025-12-09 16:38:30 +01:00
Sander Declerck
40650e7912
Add tests for: not shortcircuiting timeout on imds endpoint. 2025-12-09 15:46:37 +01:00
Sander Declerck
afc68618c6
Only timeout for imds endpoints 2025-12-09 15:25:19 +01:00
Reinier Criel
5d1807a551 Remove unnecessary change 2025-12-08 17:30:55 -08:00
Reinier Criel
23922dfb2d Fix test issue 2025-12-08 16:53:07 -08:00
Reinier Criel
b84b410fd8 Fix linting issues 2025-12-08 15:36:37 -08:00
Reinier Criel
c51956b2db Fix tests 2025-12-08 15:23:44 -08:00
Reinier Criel
d9fe775d11 Fix some issues 2025-12-08 15:18:06 -08:00
Reinier Criel
2bc6d249de Some fixes 2025-12-08 13:38:38 -08:00
Reinier Criel
091e6ec5f8 Merge branch 'main' into feature/combine-certs 2025-12-08 09:42:10 -08:00
bitterpanda
cef2194427
Merge pull request #225 from AikidoSec/fix-url-in-output-logs
Fix undefined url in output logs
2025-12-08 13:00:22 +01:00
bitterpanda
0931b6a5fe
Merge pull request #224 from AikidoSec/only-audit-stats-in-verbose
Log audit stats as verbose, not as information
2025-12-08 11:56:55 +01:00
Sander Declerck
19aed47f02
Add typedef for MalwareBlockedEvent 2025-12-08 11:54:30 +01:00
Sander Declerck
4840b0f694
Fix undefined url in output logs 2025-12-08 11:50:57 +01:00
Sander Declerck
a7946377b4
Log audit stats as verbose, not as information 2025-12-08 11:37:37 +01:00
bitterpanda
9901cb8502
Merge pull request #100 from AikidoSec/test-on-win
Run unit tests on windows
2025-12-08 10:19:33 +01:00
Sander Declerck
6e09aa85c2
Merge branch 'main' into test-on-win 2025-12-08 09:49:52 +01:00
bitterpanda
b40a9dd6f5
Merge pull request #222 from AikidoSec/bug/clear-pip-cache
Clear pip/uv/poetry cache before e2e tests
2025-12-07 22:08:10 +01:00
Reinier Criel
2e9bae41f3 Add unit tests 2025-12-05 15:40:14 -08:00
Reinier Criel
d0c5f35707 Check input file 2025-12-05 15:31:19 -08:00
Reinier Criel
8aa0615293 Some improvements 2025-12-05 15:13:12 -08:00
Reinier Criel
7086cfa277 Combine NODE_EXTRA_CA_CERTS with Safe Chain's certificate bundle 2025-12-05 14:26:23 -08:00
Reinier Criel
fc88120fdc Also for uv and poetry 2025-12-05 10:01:55 -08:00
Reinier Criel
85c4fcc96f Make sure e2e test clears cache 2025-12-05 09:39:51 -08:00
Sander Declerck
19399b491b
Only upload artifact on linux 2025-12-05 18:10:41 +01:00
Sander Declerck
dfed1299c4
Overwrite artifact 2025-12-05 18:09:22 +01:00
Sander Declerck
46cbb4fd28
Ignore scripts when running npm ci on Windows 2025-12-05 18:06:16 +01:00
Sander Declerck
bf674a0e5c
Merge branch 'main' into test-on-win 2025-12-05 18:05:02 +01:00
bitterpanda
15cc6ff7fe
Merge pull request #178 from AikidoSec/feature/poetry-2
Add Poetry support
2025-12-05 15:56:20 +01:00
bitterpanda
2dd215d620
Merge pull request #220 from AikidoSec/feature/pypi-cleanup-2
[PYPI] Centralize pip/python bypass logic in runPipCommand
2025-12-05 14:26:17 +01:00
Sander Declerck
883bae737c
Merge pull request #214 from AikidoSec/pwsh-join-path-issue
Fix Join-Path error for Windows Powershell
2025-12-05 14:07:12 +01:00
Sander Declerck
f097b9e66d
Merge pull request #215 from AikidoSec/connect-timeout-beta
Reduce connect timeout for tunnel for known instance metadata hosts
2025-12-05 14:06:34 +01:00
Sander Declerck
e421414b8a
Don't repeatedly call isImdsEndpoint 2025-12-05 12:12:22 +01:00
Sander Declerck
57a0e88fa4
Add tests and clarifying comments 2025-12-05 12:09:19 +01:00
Reinier Criel
e211f531c5 Refactor PyPI logic and cleanup 2025-12-04 12:37:59 -08:00
Sander Declerck
22b93e91f6
Use "beta" as tag 2025-12-04 16:16:31 +01:00
Reinier Criel
d018246292 More cleanup 2025-12-04 07:13:32 -08:00
Sander Declerck
6d449d63c8
Fix version number when publishing to npmjs 2025-12-04 16:06:48 +01:00
Reinier Criel
940603ae73 Merge branch 'main' into feature/poetry-2 2025-12-04 07:02:08 -08:00
Sander Declerck
10a3b63a5f
Add --tag to npm publish 2025-12-04 15:54:26 +01:00
Sander Declerck
a9ebec14f6
Remove 192.0.2.1 2025-12-04 15:21:47 +01:00
Sander Declerck
47ea989bbd
Reduce connect timeout for tunnel for known instance metadata hosts 2025-12-04 15:20:47 +01:00
Sander Declerck
aadd083b9e
Fix Join-Path error for Windows Powershell 2025-12-04 11:35:32 +01:00
Reinier Criel
297a264fe0 Adapt per comments 2025-12-03 15:40:02 -08:00
Reinier Criel
890fee83ad Update README 2025-12-03 13:29:24 -08:00
Reinier Criel
11bd3a2b91 Some more improvements 2025-12-03 09:54:25 -08:00
Reinier Criel
cfedb6df99 Some comment updates 2025-12-03 09:20:54 -08:00
Reinier Criel
b1da6af30b Extend E2E Test 2025-12-03 08:24:37 -08:00
Reinier Criel
82416456a0 Some small fixes 2025-12-03 07:58:09 -08:00
Sander Declerck
e7cf3488b7
Merge pull request #202 from AikidoSec/compress-binaries
log size of binaries
2025-12-03 16:45:19 +01:00
Reinier Criel
c1a12c9573 Merge branch 'main' into feature/poetry-2 2025-12-03 07:41:52 -08:00
bitterpanda
75f8767819 needs to be safe-chain.exe instead of safe-chain.cmd for size 2025-12-03 16:30:19 +01:00
bitterpanda
6fa648d6ca make compat with windows: sze reporting 2025-12-03 16:27:25 +01:00
bitterpanda
3a1d9c25af rm --compress for now 2025-12-03 16:25:42 +01:00
bitterpanda
7abbd4aee9 report total size at the end 2025-12-03 16:18:24 +01:00
bitterpanda
0a4c6ed5db fi pkgArgs build 2025-12-03 16:10:44 +01:00
BitterPanda
a6c6a6663b Merge branch 'main' into compress-binaries 2025-12-03 16:08:05 +01:00
bitterpanda
de1fd001d6
Merge pull request #207 from AikidoSec/improve-build-stdio
Improve build stdio
2025-12-03 16:06:35 +01:00
bitterpanda
68ed31c6ee
Merge pull request #208 from AikidoSec/readme-fixes
Hard-code links and remove outdated information from readme
2025-12-03 15:59:53 +01:00
Sander Declerck
fa7be54ee9
Merge pull request #206 from AikidoSec/fix-powershell-install-script
Fix scoping in powershell script
2025-12-03 15:55:18 +01:00
Sander Declerck
b64d84c252
Hard-code links and remove outdated information from readme 2025-12-03 15:54:03 +01:00
bitterpanda
267a5ab423 add spacing where necessry in build.js 2025-12-03 15:33:16 +01:00
bitterpanda
9da3411cc1 Add decent logging to build script 2025-12-03 15:32:51 +01:00
Sander Declerck
bdddf8f37e
Fix scoping in powershell script 2025-12-03 15:27:12 +01:00
bitterpanda
62d5af8599
Merge pull request #203 from AikidoSec/ignore-idea-editor-files
.gitignore: add .idea folder
2025-12-03 14:21:08 +01:00
bitterpanda
9bf88dfd14 .gitignore: add .idea folder 2025-12-03 14:17:07 +01:00
bitterpanda
aba771e355 add --compress GZip option to build 2025-12-03 14:14:55 +01:00
bitterpanda
9518be35b4
Merge pull request #201 from AikidoSec/mitm-improved-logging
Improve logs for MITM handler
2025-12-03 13:36:36 +01:00
Sander Declerck
3595e87cd6
Merge pull request #185 from AikidoSec/safe-chain-binaries
Safe-chain: create standalone binaries
2025-12-03 13:27:45 +01:00
Sander Declerck
2085aad005
Improve logs for MITM handler 2025-12-03 13:24:04 +01:00
Sander Declerck
019d70cc52
Fix install scripts 2025-12-03 12:02:19 +01:00
Sander Declerck
ac6567ba59
Make scripts release-proof again 2025-12-03 11:58:33 +01:00
Sander Declerck
a578ee7213
Fix windows build 2025-12-03 11:42:22 +01:00
Sander Declerck
0fd54b159b
Lock down @yao-pkg/pkg dependency 2025-12-03 11:38:30 +01:00
Sander Declerck
aa441e7483
Add comments for esm vs cjs __dirname implementation 2025-12-03 11:38:29 +01:00
Sander Declerck
b366466e11
Modify install scripts 2025-12-03 11:38:29 +01:00
bitterpanda
c0076091c2
Update packages/safe-chain/bin/safe-chain.js 2025-12-03 11:10:47 +01:00
Sander Declerck
4139275b76
Handle PR comments 2025-12-03 10:54:49 +01:00
bitterpanda
31a14a3f1b
Update packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 2025-12-03 10:47:28 +01:00
Reinier Criel
d782ca68af
Merge pull request #198 from AikidoSec/bug/pip-config
For config-related commands, don't inject custom config, to allow persistent config/cache access
2025-12-02 13:47:10 -08:00
bitterpanda
e616626c16
Merge pull request #200 from AikidoSec/npm-badges
Add NPM version and downloads badges
2025-12-02 22:45:45 +01:00
Hans Ott
b7453c6700
Add NPM version and downloads badges 2025-12-02 19:05:05 +01:00
Reinier Criel
20e63a58be Add a better e2e test to cover the issue 2025-12-02 09:45:04 -08:00
Reinier Criel
795e7af23e Clean up comments 2025-12-02 08:44:43 -08:00
Reinier Criel
a4f9f590a4 Don't modify config for config related commands 2025-12-02 08:31:47 -08:00
Sander Declerck
dc6f16a034
PR comments 2025-12-02 15:28:59 +01:00
Sander Declerck
ce1a2a6ca6
Remove todo list 2025-12-02 15:17:58 +01:00
Sander Declerck
b632e0acda
Fix windows shim 2025-12-02 15:00:51 +01:00
Sander Declerck
998d0c4faf
Update scripts 2025-12-02 14:21:50 +01:00
Sander Declerck
f9b16cf03c
Fix bins path for CI 2025-12-02 14:19:53 +01:00
Sander Declerck
3002d27273
Fix safe-chain in CI 2025-12-02 13:58:27 +01:00
Sander Declerck
9e1bdd4a31
Update docs: migration guide 2025-12-02 11:57:23 +01:00
Sander Declerck
c4a33ca151
Update readme.md 2025-12-02 10:30:59 +01:00
Sander Declerck
2d87e1b817
Improve volta installation check 2025-12-02 09:49:14 +01:00
Sander Declerck
b60cb63fdb
Add --include-python and --ci args 2025-12-02 09:26:16 +01:00
Sander Declerck
eddb4f3f75
Check for volta installation 2025-12-02 08:43:38 +01:00
Reinier Criel
292345f709 Fix some comments 2025-12-01 12:45:06 -08:00
Sander Declerck
22b780ddcd
Remove npm-installed safe-chain 2025-12-01 16:04:47 +01:00
Sander Declerck
34c62c5268
Improve install script output 2025-12-01 15:28:10 +01:00
Sander Declerck
b6ea61170f
Update release version 2025-12-01 14:59:20 +01:00
Sander Declerck
8f80266ad3
Update powershell scripts and installation scripts 2025-12-01 14:52:15 +01:00
Sander Declerck
e58e77bc63
Install scripts 2025-12-01 14:39:41 +01:00
Sander Declerck
6f583ce396
Rename build artifacts 2025-12-01 14:09:05 +01:00
Sander Declerck
3f60ea15f7
Set release version on PR build 2025-12-01 13:28:11 +01:00
Sander Declerck
20e9826ef0
Modify release pipeline to attach the binaries. 2025-12-01 12:31:55 +01:00
Sander Declerck
2e57057baa
Update path wrappers 2025-12-01 09:57:28 +01:00
Reinier Criel
a6423763e7 More package names 2025-11-30 20:30:35 -08:00
Reinier Criel
5a7a9dd03e Fix test to account for normalization 2025-11-30 20:28:06 -08:00
Reinier Criel
c7edefd247 Fix issue during manual testing 2025-11-30 20:25:13 -08:00
Sander Declerck
1361abc4e8
Fix top-level await 2025-11-28 18:06:31 +01:00
Sander Declerck
8852afb5fa
Fix e2e tests 2025-11-28 18:05:09 +01:00
Sander Declerck
edec6ec57c
Update shell scripts 2025-11-28 16:51:39 +01:00
Sander Declerck
3af8b694fe
Linux arm64: use node 20 2025-11-28 16:36:33 +01:00
Sander Declerck
ab446e081d
Restore fork 2025-11-28 16:33:09 +01:00
Sander Declerck
552fd37294
Remove certificate command 2025-11-28 16:30:18 +01:00
Sander Declerck
8d82d4d56f
Clean up the PR 2025-11-28 16:28:45 +01:00
Sander Declerck
3add7aa25e
Remove debugging from certUtils.js 2025-11-28 16:26:31 +01:00
Sander Declerck
35ab58c440
Try package downgrade 2025-11-28 15:53:38 +01:00
Sander Declerck
ae9bc8a75d
Try to get it to work :/ 2025-11-28 15:44:45 +01:00
Sander Declerck
95d436100d
Again, try pure javascript 2025-11-28 15:40:50 +01:00
Sander Declerck
20420f865e
Try set pure javascript for node-forge 2025-11-28 15:32:52 +01:00
Sander Declerck
8ab4d2955a
Add debug logs 2025-11-28 15:27:40 +01:00
Sander Declerck
51616dda77
Comment out cert generation 2025-11-28 15:23:52 +01:00
Sander Declerck
ec9a266164
Include node-forge in binary again 2025-11-28 15:17:29 +01:00
Sander Declerck
1d00084202
Externalize node-forge 2025-11-28 15:03:36 +01:00
Sander Declerck
bb3e50008a
Forge: usePureJavaScript 2025-11-28 14:59:28 +01:00
Sander Declerck
0fffcf2cc1
Add certificate command 2025-11-28 14:51:54 +01:00
Sander Declerck
c59b8263ca
Add certify 2025-11-28 14:44:47 +01:00
Sander Declerck
161f256066
Change pwsh startup script 2025-11-28 14:10:01 +01:00
Sander Declerck
a3bff105cc
Update startup scripts to use safe-chain instead of aikido-* 2025-11-28 14:01:11 +01:00
Sander Declerck
f1ee6567df
Fix __dirname for esm / fix e2e tests. 2025-11-28 12:57:48 +01:00
Sander Declerck
8c2e8c9597
Build safe-chain binaries in build.js 2025-11-28 11:31:47 +01:00
Sander Declerck
832708299f
Use @yao-pkg/pkg 2025-11-28 11:29:40 +01:00
Sander Declerck
97883a42c2
Use node 24 2025-11-28 11:24:08 +01:00
Sander Declerck
7f1710fb73
Move target to package.json 2025-11-28 11:17:28 +01:00
Sander Declerck
05f1289268
Run pkg from ci step 2025-11-28 11:14:14 +01:00
Sander Declerck
c70659b7a1
Use correct pkg arg 2025-11-28 11:06:43 +01:00
Sander Declerck
bc51c839d0
Try fix build again 2025-11-28 11:02:48 +01:00
Sander Declerck
ccc8d685b2
Don't fail-fast in the pipeline matrix 2025-11-28 10:59:52 +01:00
Sander Declerck
8733f53b6b
Debug build 2025-11-28 10:58:10 +01:00
Sander Declerck
ae514d60d8
Use shell for pkg 2025-11-28 10:56:34 +01:00
Sander Declerck
a013141118
Try to fix the build 2025-11-28 10:55:29 +01:00
Sander Declerck
9c149f3bb3
Create and run build.js 2025-11-28 10:51:43 +01:00
Reinier Criel
26157cf5a7 Fix type check 2025-11-27 14:02:37 -08:00
Reinier Criel
d863cc6920 Another iteration 2025-11-27 14:00:34 -08:00
Reinier Criel
7ddeb9025b Fix certUtils 2025-11-27 13:34:34 -08:00
Reinier Criel
2810a87cd0 Another try 2025-11-27 13:25:53 -08:00
Reinier Criel
0106767c35 Another try 2025-11-27 13:23:03 -08:00
Reinier Criel
bbbbe4d32a Add lazy loading for certs 2025-11-27 13:19:17 -08:00
Reinier Criel
0ee5106b7a Fix function placement 2025-11-27 13:08:35 -08:00
Reinier Criel
a0bbe38ee7 Change back to localhost for testing 2025-11-27 13:03:39 -08:00
Reinier Criel
7ab51a992c Merge branch 'main' into feature/poetry-2 2025-11-27 12:54:55 -08:00
Sander Declerck
98231b8d25
Ignore scripts on install for binaries 2025-11-27 15:30:39 +01:00
Sander Declerck
dbbe0f27bf
Speed up Windows 2025-11-27 15:26:40 +01:00
Sander Declerck
543f10657c
Separate pipeline for binary creation 2025-11-27 15:21:36 +01:00
Sander Declerck
afbf3d94c2
Merge branch 'main' into safe-chain-binaries 2025-11-27 15:14:52 +01:00
Sander Declerck
a632ef9bdd
Add the correct binaries 2025-11-27 15:11:11 +01:00
Sander Declerck
2eb141caa3
Merge pull request #181 from AikidoSec/update-dependencies
Update node-forge, npm-registry-fetch and make-fetch-happen
2025-11-27 15:08:50 +01:00
Sander Declerck
430792626b
Publish the created binaries 2025-11-27 15:07:54 +01:00
Sander Declerck
b14ff4cb33
First time build of the safe-chain binaries 2025-11-27 15:01:57 +01:00
Sander Declerck
c5b4fbf238
Update node-forge, npm-registry-fetch and make-fetch-happen 2025-11-27 10:34:11 +01:00
Hans Ott
72d6acaa7f
Merge pull request #177 from AikidoSec/banner2
Add banner for safe-chain
2025-11-27 09:57:07 +01:00
Reinier Criel
5b479ef69e Some cleanup 2025-11-26 15:53:01 -08:00
Reinier Criel
f5af26092a Fix cert issues in Virtual Environments 2025-11-26 15:48:29 -08:00
Reinier Criel
9c55a95eb9 Fix e2e tests 2025-11-26 14:31:11 -08:00
Reinier Criel
4bfc315b57 Skeleton 2025-11-26 14:13:49 -08:00
Hans Ott
da1d76e43f Update banner with new tag line 2025-11-26 18:23:53 +01:00
Hans Ott
3140dcc071 Add banner for safe-chain 2025-11-26 17:40:18 +01:00
Sander Declerck
a57c37b58d
Merge pull request #176 from AikidoSec/min-package-age-configuration 2025-11-26 17:24:25 +01:00
Sander Declerck
9b5b3cad22
Rename the environment variable 2025-11-26 16:47:46 +01:00
Sander Declerck
3e6ff1ab56
Update readme file 2025-11-26 16:46:01 +01:00
Sander Declerck
13892efa70
Allow to configure the minimum package age 2025-11-26 16:42:51 +01:00
Sander Declerck
dc6c657d41
Merge pull request #162 from AikidoSec/readme-update-intro
Update intro in README.md
2025-11-26 16:41:55 +01:00
Sander Declerck
3ceed1fc4b
Merge branch 'main' into readme-update-intro 2025-11-26 16:31:41 +01:00
bitterpanda
5c3c3399d9
Merge pull request #168 from AikidoSec/feature/uv
Add uv (Astral Python Package Mgr) support
2025-11-26 13:20:45 +01:00
Reinier Criel
023bccec11 Some more cleanup 2025-11-25 19:55:36 -08:00
Reinier Criel
5cb1bb935b More cleanup' 2025-11-25 15:03:33 -08:00
Reinier Criel
e03bceba88 Some cleanup 2025-11-25 14:37:31 -08:00
Reinier Criel
cab3a0aba3 Add uv (Astral Python package manager) support
- Add uv package manager implementation following pip pattern
- Configure MITM proxy with CA bundle for PyPI packages
- Add shell integration (bash/zsh/fish/PowerShell)
- Conditional on --include-python flag
- Add 33 comprehensive E2E tests covering:
  - uv pip install/sync/compile commands
  - uv add for project dependencies
  - uv tool install for global tools
  - uv run --with for ephemeral dependencies
  - uv sync for project syncing
  - Malware blocking verification for all methods
- Update documentation and package.json
- Install uv in Docker test environment
2025-11-25 14:10:20 -08:00
Sander Declerck
5b6fe659c2
Merge pull request #164 from AikidoSec/remove-safe-chain-bun
Remove the safe-chain-bun package
2025-11-25 16:07:23 +01:00
Sander Declerck
156522912e
Remove the safe-chain-bun package 2025-11-25 15:10:42 +01:00
Sander Declerck
1d50748f32
Merge pull request #163 from AikidoSec/remove-ora
Remove ora dependency
2025-11-25 15:07:45 +01:00
Sander Declerck
77e9d3d843
Fix e2e tests 2025-11-25 14:56:12 +01:00
Sander Declerck
c8df7566b5
Remove ora dependency 2025-11-25 14:22:31 +01:00
Sander Declerck
eac173dfa3
Update intro in README.md 2025-11-25 12:31:50 +01:00
Sander Declerck
d158e15c08
Merge pull request #159 from AikidoSec/publish-using-oidc
Publish using OIDC
2025-11-25 09:16:17 +01:00
Hans Ott
e976c28b8a Publish using OIDC 2025-11-24 18:45:14 +01:00
Sander Declerck
fb3a8582a2
Merge pull request #158 from AikidoSec/prevent-packagemanager-from-caching-modified-response
Prevent package manager from caching modified response
2025-11-24 18:37:44 +01:00
Sander Declerck
c695d0cb5d
Add explaining comment 2025-11-24 18:29:35 +01:00
Sander Declerck
5629b640cc
Prevent package manager from caching modified response 2025-11-24 18:16:09 +01:00
Sander Declerck
f6400e9822
Merge pull request #151 from AikidoSec/package-min-age
npm: Minimum package age
2025-11-24 16:14:02 +01:00
Sander Declerck
900bf8e6ea
Parse npm registry's timestamps. 2025-11-24 15:52:17 +01:00
Sander Declerck
ea75179143
Update readme to reflect our support for node 16+ and delete broken screenshot. 2025-11-24 15:31:30 +01:00
Sander Declerck
0a8dacda24
Add small comment on why we're removing the host header before forwarding. 2025-11-24 15:31:30 +01:00
Sander Declerck
faae0488c8
Undo small refactor 2025-11-24 15:31:30 +01:00
Sander Declerck
44ee58aa9b
Let modifyNpmInfoRequestHeaders return the header collection as well. 2025-11-24 15:31:30 +01:00
Sander Declerck
5834229427
Add comment in interceptorBuilder.js to clarify which api is for setup, and which api is used by the proxy. 2025-11-24 15:31:30 +01:00
Sander Declerck
9a1092199d
Move getHeaderValueAsString to separate utils file 2025-11-24 15:31:30 +01:00
Sander Declerck
78c8da6fae
Restore old "how it works" text in Readme.md 2025-11-24 15:31:30 +01:00
Sander Declerck
e02e36cfea
Apply suggestion from @bitterpanda63
Adds comment about "utf8" encoding of json response.

Co-authored-by: bitterpanda <bitterpanda@proton.me>
2025-11-24 14:49:40 +01:00
Sander Declerck
f7de81645c
Fix cliArgument.js merge issue 2025-11-24 14:17:47 +01:00
Sander Declerck
a04bea26da
Merge branch 'main' into package-min-age 2025-11-24 14:15:55 +01:00
Sander Declerck
f34fb3576d
Merge pull request #152 from AikidoSec/pypi-feature-flag
Add feature flag in setup for python support.
2025-11-24 10:00:23 +01:00
Reinier Criel
a0dc6536b1
Merge pull request #147 from AikidoSec/feature/cert-beta
Create INI file for pip to make sure behavior is predictable
2025-11-21 13:27:57 -08:00
Reinier Criel
72bf44cb6d Fix linting issue 2025-11-21 10:31:57 -08:00
Reinier Criel
ab1aa0dce9 Little cleanup 2025-11-21 09:58:43 -08:00
Reinier Criel
0a0ac85542 Adapt per review 2025-11-21 09:41:07 -08:00
bitterpanda
f030b16adf
rm obvious comments 2025-11-21 13:33:33 +01:00
Reinier Criel
0e5b9b23f1 Fix tests 2025-11-17 10:18:47 -08:00
Reinier Criel
87fcb7239a Adapt per review 2025-11-17 10:03:38 -08:00
Sander Declerck
41998dff95
Describe safe-chain setup --include-python in documentation. 2025-11-14 14:18:12 +01:00
Sander Declerck
c6bcd6f646
Add feature flag in setup for python support. 2025-11-14 14:12:44 +01:00
Sander Declerck
59963a6f34
Make warning in readme less prominent 2025-11-14 11:40:29 +01:00
Sander Declerck
ddf867bf53
Fix readme indentation 2025-11-14 10:41:53 +01:00
Sander Declerck
de27856640
Merge branch 'main' into package-min-age 2025-11-14 10:36:34 +01:00
bitterpanda
4b5bef8d6a
Clarify support for ecosystems and pip status
Updated README to clarify that Aikido Safe Chain currently supports only JavaScript ecosystems and marks pip and pip3 as beta.
2025-11-14 10:35:57 +01:00
Sander Declerck
157725a25a
Cleanup 2025-11-14 10:29:09 +01:00
Sander Declerck
290a630526
Better header check + remove last-modified header 2025-11-14 10:23:06 +01:00
Sander Declerck
40523f29dd
Document minimum package age in README.md 2025-11-14 09:30:58 +01:00
bitterpanda
86fb69a931
Clarify support for ecosystems and pip status
Updated README to clarify that Aikido Safe Chain currently supports only JavaScript ecosystems and marks pip and pip3 as beta.
2025-11-14 09:30:58 +01:00
Sander Declerck
06b287d4d4
Use correct header collection for forwarding 2025-11-14 09:08:27 +01:00
Reinier Criel
7039961d4c Bugfix 2025-11-13 15:50:37 -08:00
Reinier Criel
0b3cc1c175 Some more cleanup 2025-11-13 15:50:14 -08:00
Reinier Criel
474d91d29a Indentation 2025-11-13 13:32:49 -08:00
Reinier Criel
f4ff18304a Fix imports 2025-11-13 13:20:11 -08:00
Reinier Criel
4ee18973de Fix unit test 2025-11-13 12:48:04 -08:00
Reinier Criel
a0e24b1722 Update comments 2025-11-13 11:21:53 -08:00
Reinier Criel
84b8c2f2cf Merge branch 'main' into feature/cert-beta 2025-11-13 11:15:33 -08:00
Reinier Criel
61c9f1a1ef Merge config file if it exists 2025-11-13 11:14:45 -08:00
Sander Declerck
59fa76a42f
Notify the user when we modified the package versions 2025-11-13 17:10:22 +01:00
Sander Declerck
dc6f37b3ec
Remove etag from response when modifying headers 2025-11-13 16:27:42 +01:00
Sander Declerck
752504dcc8
Add --safe-chain-skip-minimum-package-age cli flag 2025-11-13 16:04:24 +01:00
Sander Declerck
f64ee3bccf
Add skipMinimumPackageAge. 2025-11-13 15:14:44 +01:00
Sander Declerck
a9a4d76705
Fix type error in modifyNpmInfo.js 2025-11-13 15:08:36 +01:00
Sander Declerck
6b208a8730
Merge pull request #150 from AikidoSec/bitterpanda63-patch-1-1
Mark python support as beta for now
2025-11-13 14:53:45 +01:00
Sander Declerck
6ae93686b7
Finish npm info modification. 2025-11-13 14:51:57 +01:00
Reinier Criel
fbd11c6d44 Update 2025-11-12 14:01:06 -08:00
Reinier Criel
285906ea9d Update doc 2025-11-12 13:39:58 -08:00
Reinier Criel
f215368c4a Some small fixes 2025-11-12 13:30:22 -08:00
Reinier Criel
fdef9e0766 Some tweaks 2025-11-12 13:11:02 -08:00
bitterpanda
988507f8e1
Clarify support for ecosystems and pip status
Updated README to clarify that Aikido Safe Chain currently supports only JavaScript ecosystems and marks pip and pip3 as beta.
2025-11-12 16:15:32 +01:00
Sander Declerck
3b905d490b
Merge branch 'main' into package-min-age 2025-11-12 14:42:19 +01:00
bitterpanda
bb0d06cdfc
Merge pull request #144 from AikidoSec/only-write-stdout-when-safe-chain-audited
Add interceptors for MITM
2025-11-12 14:27:27 +01:00
Sander Declerck
27bf768cc6
Remove blockResponse function entirely 2025-11-12 14:12:45 +01:00
Sander Declerck
d8007f6236
Cleanup interceptorBuilder.js 2025-11-12 14:07:35 +01:00
Sander Declerck
ad6d9bcdd5
Simplify interceptor code and rename variables for clarity. 2025-11-12 14:03:33 +01:00
Sander Declerck
2cf23d5109
Don't expose blockRequest 2025-11-12 13:43:47 +01:00
Sander Declerck
8bd2ace3db
Remove too new packages from npm response 2025-11-12 13:39:17 +01:00
Reinier Criel
f2bf5869ba Fix linting issue 2025-11-11 15:49:25 -08:00
Reinier Criel
a3d57cbd24 Cleanup 2025-11-11 15:24:59 -08:00
Reinier Criel
6bcd3d3b8f Make sure we don't override any environments 2025-11-11 15:22:06 -08:00
Reinier Criel
f9d241e474 Fix unused import 2025-11-11 14:32:12 -08:00
Reinier Criel
6a94271a10 Do not add list of trusted hosts, is security risk 2025-11-11 14:28:31 -08:00
Reinier Criel
9b102412af Add extra ENV vars 2025-11-11 10:37:39 -08:00
Sander Declerck
3bf7279195
Implement modification of request headerrs 2025-11-07 16:16:37 +01:00
Reinier Criel
76acf43128
Merge pull request #142 from AikidoSec/feature/pypi-ci
[PYPI] Add CI Shims
2025-11-07 06:54:28 -08:00
Sander Declerck
76a1100b8c
Fix linter issues 2025-11-07 11:42:53 +01:00
Sander Declerck
1f570a9f39
Keep track of amount of malware packages blocked 2025-11-07 11:39:41 +01:00
Sander Declerck
f4694ba119
Move npm and pip mitm interception to separate files 2025-11-07 10:10:27 +01:00
Reinier Criel
d3a4f81b3c More cleanup 2025-11-06 13:44:34 -08:00
Reinier Criel
01cc0b06c0 Reverse e2e test removals 2025-11-06 13:40:09 -08:00
Reinier Criel
61a53b24fd Some cleanup 2025-11-06 13:24:00 -08:00
Reinier Criel
2632b5c2af Merge remote-tracking branch 'origin/feature/pypi-ci' into feature/pypi-ci 2025-11-06 13:00:46 -08:00
Reinier Criel
a293c76ed9 Add better logging 2025-11-06 12:53:24 -08:00
Reinier Criel
e88aede939 Remove some debug logging 2025-11-06 12:25:55 -08:00
Reinier Criel
dd2894faab Extend test 2025-11-06 11:30:13 -08:00
Reinier Criel
032fc3847f Fix args 2025-11-06 11:09:28 -08:00
Reinier Criel
9bd29056c6 Some cleanup 2025-11-06 11:02:03 -08:00
Reinier Criel
a6956db8dc Remove debug log 2025-11-06 10:27:49 -08:00
Reinier Criel
28d24bb6ea Another iteration 2025-11-06 10:26:26 -08:00
Sander Declerck
e251908cb3
Add interceptors for MITM 2025-11-06 18:01:20 +01:00
Reinier Criel
f400c5576a WIP 2025-11-06 08:32:25 -08:00
Reinier Criel
7a39b1381b Merge branch 'feature/pypi-ci' of github.com:AikidoSec/safe-chain into feature/pypi-ci 2025-11-05 19:45:37 -08:00
Reinier Criel
0a3028329f Fix template 2025-11-05 16:32:57 -08:00
Reinier Criel
84cf485b31 Add comment explaining forwarding 2025-11-05 16:24:57 -08:00
Reinier Criel
fa4c46c23d Cleanup readme 2025-11-05 15:47:41 -08:00
Reinier Criel
7cff2818e4 Fix Windows template 2025-11-05 15:40:54 -08:00
Reinier Criel
ec4228edc1 Add more test cases 2025-11-05 11:23:37 -08:00
Reinier Criel
216e16cfb1 Fix e2e test 2025-11-05 11:13:24 -08:00
Reinier Criel
35bd3dfb6f Merge branch 'main' into feature/pypi-ci 2025-11-05 10:35:59 -08:00
bitterpanda
60dc3f6d82
Merge pull request #140 from AikidoSec/feature/pypi-remove-args-parsing
[PYPI] Remove CLI Parsing - Use MITM only
2025-11-05 19:28:19 +01:00
Reinier Criel
3b56a0181f Update comment 2025-11-05 09:55:09 -08:00
Reinier Criel
bded1fe660 Fix test 2025-11-05 09:28:57 -08:00
Reinier Criel
87606def48 Fix comments 2025-11-05 09:18:18 -08:00
Reinier Criel
3cfe00e535 Merge branch 'main' into feature/pypi-remove-args-parsing 2025-11-05 09:01:57 -08:00
bitterpanda
96860fb93d
Merge pull request #138 from AikidoSec/only-write-stdout-when-safe-chain-audited
Only write to stdout when safe-chain audited packages
2025-11-05 17:51:57 +01:00
Reinier Criel
f0a3ae51db Only use mitm for pip packages 2025-11-05 08:34:40 -08:00
Sander Declerck
0b056e92de
Merge branch 'main' into only-write-stdout-when-safe-chain-audited 2025-11-05 17:12:57 +01:00
bitterpanda
96d7c460fa
Merge pull request #139 from AikidoSec/feature/fix-e2e-tests
[PYPI e2e testing] Add extra flag to install commands
2025-11-05 17:10:50 +01:00
Reinier Criel
9f0f50eb15 Small fix 2025-11-05 07:57:29 -08:00
Reinier Criel
9c23345f1c Add flags to prevent errors in Docker image 2025-11-05 07:29:57 -08:00
Sander Declerck
378b0ac7c9
Rename verifiedPackages to totalPackages, fix e2e tests 2025-11-05 12:19:47 +01:00
Sander Declerck
e4c40330f7
Only write to stdout when safe-chain audited packages 2025-11-05 12:01:08 +01:00
Reinier Criel
03312cd707 Clean up logging 2025-11-04 14:34:26 -08:00
Reinier Criel
58a5e837f7 Add unit tests 2025-11-04 13:32:07 -08:00
Reinier Criel
6241c56fda Skeleton for CI support 2025-11-04 13:29:31 -08:00
Sander Declerck
18f30ac66e
Merge pull request #124 from reiniercriel/feature/pypi
Add Python (pip) support for malware scanning
2025-11-04 19:29:19 +01:00
Reinier Criel
2b6b9b6737 Cleanup comments 2025-11-04 06:59:45 -08:00
Reinier Criel
d789491561 Merge branch 'main' into feature/pypi 2025-11-04 06:54:00 -08:00
bitterpanda
fa0cc710ef
Merge pull request #137 from AikidoSec/remove-yarn-version-check 2025-11-04 13:43:17 +01:00
Sander Declerck
497401e8e0
Remove yarn version check 2025-11-04 13:18:36 +01:00
bitterpanda
8db8839d90
Merge pull request #133 from AikidoSec/remove-ts-suppressions 2025-11-04 12:21:39 +01:00
Sander Declerck
3ea4e82acb
Write a warning if no version was returned from the malware download, causing the malware db not to be cached. 2025-11-04 11:26:07 +01:00
Sander Declerck
e79fbded9e
Merge branch 'main' into remove-ts-suppressions 2025-11-04 11:05:08 +01:00
bitterpanda
1f208d8784
Merge pull request #128 from AikidoSec/verbose-logging 2025-11-04 11:01:12 +01:00
Reinier Criel
86f82d6065 Fix more documentation issues 2025-11-03 10:53:35 -08:00
Reinier Criel
f7e08bbea8 Fix more documentation issues 2025-11-03 10:44:12 -08:00
Reinier Criel
2accf954ca Fix more documentation issues 2025-11-03 10:20:05 -08:00
Reinier Criel
dadb1a3fba Adapt runPipCommand.js documentation 2025-11-03 09:55:39 -08:00
Reinier Criel
181470d764 Clean up 2025-11-03 09:49:06 -08:00
Reinier Criel
e65b857667 Adapt comments to align with other package managers 2025-11-03 09:47:16 -08:00
Reinier Criel
9a0b6f45bb Use comment iso type checking 2025-11-03 08:12:48 -08:00
Sander Declerck
c1eeafedf0
Merge branch 'main' into remove-ts-suppressions 2025-11-03 17:00:03 +01:00
Reinier Criel
bffb1995bd Fix lock file 2025-11-03 07:19:08 -08:00
Reinier Criel
a2fb94d0f0 Fix type check issues 2025-11-03 07:13:36 -08:00
Reinier Criel
3d98bb5084 Fix package-lock.json 2025-11-03 07:07:41 -08:00
Reinier Criel
27ca2153b0 Fix warnings 2025-11-03 06:51:14 -08:00
Reinier Criel
548d416996 Merge remote-tracking branch 'origin/main' into feature/pypi 2025-11-03 06:49:53 -08:00
bitterpanda
e0fbb7e18a
Merge pull request #134 from AikidoSec/configfile-better-input-handling
Add better error handling, tests and type checks for configFile.js
2025-11-03 15:07:55 +01:00
Sander Declerck
8c872b3861
Better error handling and extract validation logic to a re-usable function. 2025-11-03 14:54:42 +01:00
Sander Declerck
1e7cd74364
Mock filesystem in configFile.spec.js 2025-11-03 14:49:44 +01:00
Sander Declerck
5304a7744a
Add better error handling, tests and type checks for configFile.js 2025-11-03 14:41:29 +01:00
Sander Declerck
14c4c4997e
Remove @ts-expect-error suppressions 2025-11-03 13:57:29 +01:00
Sander Declerck
932ea6b8f9
Add type information for new functions. 2025-11-03 11:47:59 +01:00
Sander Declerck
be6a6dccd9
Merge branch 'main' into verbose-logging 2025-11-03 11:37:47 +01:00
bitterpanda
aa5c74c477
Merge pull request #132 from AikidoSec/type-check
Type check safe-chain package
2025-11-03 11:36:22 +01:00
Hans Ott
855f6a417f Use original notation 2025-11-03 11:31:04 +01:00
Hans Ott
910276deeb Fix type 2025-11-03 11:30:21 +01:00
Hans Ott
c3a62826d4 Make prop optional 2025-11-03 11:28:24 +01:00
Hans Ott
ad9551ca6d Improve types and remove async 2025-11-03 11:26:10 +01:00
Hans Ott
49d31049ac Revert code
Let's do it in a separate PR
2025-11-03 11:04:20 +01:00
Hans Ott
e8e7c85c62 Revert "Introduce mistake that passes linter"
This reverts commit 1724e0b199.
2025-11-02 15:31:23 +01:00
Hans Ott
1724e0b199 Introduce mistake that passes linter 2025-11-02 15:31:02 +01:00
Hans Ott
0cfce2d436 Revert "Example of mistake"
This reverts commit b489fe822c.
2025-11-02 15:29:36 +01:00
Hans Ott
b489fe822c Example of mistake 2025-11-02 15:29:23 +01:00
Hans Ott
e164eb8b95 Reduce diff 2025-11-01 13:47:13 +01:00
Hans Ott
86a2b8c2a7 Fix lint 2025-11-01 13:44:48 +01:00
Hans Ott
484cbcd960 Use @typedef {Object} X
When you write @typedef {Object} ScanResult, you’re telling both JSDoc and TypeScript’s parser that this typedef represents an object type, not just an abstract name. This is important because it makes tools like IDEs, linters, and TypeScript’s JSDoc inference more reliable. It avoids ambiguity, especially in cases where the typedef might later be confused with something like a primitive, union, or function type. The official TypeScript documentation and the JSDoc spec both show this form as the canonical one for object shapes.
2025-11-01 13:28:11 +01:00
Hans Ott
29dd63d1eb Reduce diff 2025-11-01 13:26:15 +01:00
Hans Ott
4f14859351 Fix check 2025-11-01 13:24:57 +01:00
Hans Ott
6f962a9299 Use Node.js 18 types 2025-11-01 13:09:08 +01:00
Hans Ott
5adfb36629 Run typecheck as part of CI 2025-11-01 13:07:31 +01:00
Hans Ott
c88b1a624f Type check safe-chain package 2025-11-01 13:06:06 +01:00
Reinier Criel
be5c4fb382 Fix renaming 2025-10-31 08:07:06 -07:00
Reinier Criel
c2a9cc2733 Move pipCaBundle to central location 2025-10-31 07:51:26 -07:00
Reinier Criel
b1c09c6ff1 Merge branch 'main' into feature/pypi 2025-10-31 07:27:38 -07:00
Sander Declerck
d5dc801c00
Merge pull request #131 from AikidoSec/fix-linter-issues
Fix linter issues
2025-10-31 14:18:25 +01:00
Sander Declerck
3721ca9113
Fix linter issues 2025-10-31 13:56:35 +01:00
bitterpanda
04751df30c
Merge pull request #130 from AikidoSec/socket-error-events 2025-10-31 13:37:01 +01:00
Sander Declerck
78fd93b72a
End clientsocket without 502 in case of proxySocket error 2025-10-31 11:41:39 +01:00
Sander Declerck
4dc14397ad
Use correct event name in comment (error) 2025-10-31 11:40:01 +01:00
Sander Declerck
df5c424a42
Add missing import (ui) in mitmRequestHandler.js 2025-10-31 11:38:39 +01:00
Sander Declerck
bae43d0dcd
MITM handler: Close the response on server error 2025-10-31 11:38:16 +01:00
Sander Declerck
088c215569
Write logs on SIGTERM and SIGINT 2025-10-31 10:39:24 +01:00
Sander Declerck
efb0044419
Add global exception handlers 2025-10-31 10:26:56 +01:00
Sander Declerck
65c9ca62de
Subscribe to more error events to prevent the process from crashing 2025-10-31 09:39:16 +01:00
Reinier Criel
d691c614ac Cleanup 2025-10-30 20:19:16 -07:00
Reinier Criel
c9e7bd2ab4 Adapt e2e test to use test.pypi 2025-10-30 20:15:58 -07:00
Reinier Criel
f38a12c6d5 Combine certificates 2025-10-30 16:00:32 -07:00
Reinier Criel
1755fe829c Make test a little safer 2025-10-30 12:52:10 -07:00
Reinier Criel
8b7784ecc0 Omly pass --cert when using known registry 2025-10-30 12:36:32 -07:00
Reinier Criel
86ce7ac45e Remove unused var 2025-10-28 15:44:36 -07:00
Reinier Criel
a17e14c988 Ensure that --cert parameters do not get overriden 2025-10-28 15:02:59 -07:00
Reinier Criel
70dc89c3e8 Simplify setting certificates 2025-10-28 13:56:27 -07:00
Reinier Criel
b886bb1cfe Call safeSpawn iso safeSpawnPy 2025-10-28 13:38:31 -07:00
Reinier Criel
ccd59a2f17 Clean up code 2025-10-28 09:45:24 -07:00
Reinier Criel
684edd27a2 Fix scanning issue 2025-10-28 09:39:05 -07:00
Reinier Criel
c2e632ead2 Add e2e test for malware blocking + python3 fix 2025-10-28 09:15:00 -07:00
Reinier Criel
3c109fb5fd Fix issue seen during Windows testing 2025-10-27 15:19:48 -07:00
Reinier Criel
a438175e8a Fix tests 2025-10-27 13:28:35 -07:00
Reinier Criel
57bbb06f39 Add redirecting for explicit python(3) commands 2025-10-27 13:00:18 -07:00
Reinier Criel
f6381f5e91 Correct package-lock.json 2025-10-27 12:09:41 -07:00
Reinier Criel
8f877742d0 Fix permissions issue with aikido-pip3 2025-10-27 11:48:30 -07:00
Reinier Criel
e25146a2d2 Merge main into feature 2025-10-27 09:27:51 -07:00
Reinier Criel
190607de92 Adapt per review 2025-10-27 09:23:47 -07:00
Sander Declerck
5eeb68e355
Add documentation for verbose log level 2025-10-27 17:20:14 +01:00
Sander Declerck
ddc8218a2d
Rename writeVerboseInformation to writeVerbose 2025-10-27 17:14:45 +01:00
Sander Declerck
c5e25f4813
Add verbose logging setting + setup buffering of logs to prevent interleaving logs with the package manager. 2025-10-27 17:09:28 +01:00
Sander Declerck
0b393eeb5f
Merge branch 'main' into verbose-logging 2025-10-27 15:11:53 +01:00
bitterpanda
c284ad7ba9
Merge pull request #126 from AikidoSec/remove-malware-action-docs
Remove --safe-chain-malware-action documentation
2025-10-27 14:03:26 +01:00
Sander Declerck
ff724154fb
Remove --safe-chain-malware-action documentation 2025-10-27 13:49:29 +01:00
bitterpanda
03070b8b6a
Merge pull request #125 from AikidoSec/remove-prompt
Remove --safe-chain-malware-action flag
2025-10-27 13:18:29 +01:00
Sander Declerck
ab3319a310
Remove --safe-chain-malware-action flag 2025-10-27 11:51:19 +01:00
Sander Declerck
95d9cefcc9
Merge pull request #123 from AikidoSec/logging-silent-mode
Introduce silent mode to disable logging
2025-10-27 11:29:26 +01:00
Sander Declerck
23c8a2e324
Merge pull request #91 from AikidoSec/escape-special-chars-in-shell
Escape special chars in shell scripts
2025-10-27 11:29:09 +01:00
Sander Declerck
0029a7e1c1
Add extra comments for regex clarification 2025-10-27 10:49:26 +01:00
Reinier Criel
9dacf5cff3 Revert package-lock.json to match main 2025-10-25 14:27:05 -07:00
Reinier Criel
598ddc17fa Fix linting issue 2025-10-25 14:14:36 -07:00
Reinier Criel
38d3b46939 Some more cleanup 2025-10-25 14:03:19 -07:00
Reinier Criel
41fda7f6ed Update logging for audit 2025-10-25 13:35:18 -07:00
Reinier Criel
30a347d0b3 Cleanup readme 2025-10-24 13:51:54 -07:00
Reinier Criel
9914c0ccb3 Some fixes 2025-10-24 13:47:22 -07:00
Reinier Criel
6b2db6dace Fix ranges issue 2025-10-24 13:14:57 -07:00
Reinier Criel
15785fad73 Make sure we use a different version.txt to prevent having to redownload DB 2025-10-24 09:59:53 -07:00
Sander Declerck
f5f3b91b40
Test if command is safe to execute 2025-10-24 17:36:51 +02:00
Sander Declerck
d6dda73fb9
WIP 2025-10-24 16:21:14 +02:00
Reinier Criel
b5988e19c1 Some more cleanup 2025-10-23 13:11:51 -07:00
Reinier Criel
059cba06bc Implement e2e tests 2025-10-23 11:41:13 -07:00
Reinier Criel
f817bf887a Update documentation 2025-10-23 10:23:42 -07:00
Reinier Criel
c85802dd2e Undo unnecessary changes 2025-10-23 09:17:13 -07:00
Reinier Criel
1fdb15a392 Fix some border cases 2025-10-23 09:14:05 -07:00
Sander Declerck
0f164d055f
Fix mocking in tests 2025-10-23 17:48:26 +02:00
Sander Declerck
9a78cafbfd
Introduce silent mode to disable logging 2025-10-23 17:45:03 +02:00
Sander Declerck
7a55be49f4
Fix linting error 2025-10-23 13:29:14 +02:00
Sander Declerck
08c1328b52
Cleanup code, add some tests 2025-10-23 13:23:08 +02:00
Sander Declerck
c74c23b0ff
Fix unit tests 2025-10-23 10:52:03 +02:00
Sander Declerck
8447d3cac5
Merge branch 'main' into escape-special-chars-in-shell 2025-10-23 09:52:38 +02:00
Hans Ott
7e72ae7d3d
On Unix/macOS, pass args to spawn to avoid escaping issues 2025-10-23 09:46:15 +02:00
Reinier Criel
1b82aeb6b0 Adapt the structure to parse the initial pip commands 2025-10-22 15:28:27 -07:00
Reinier Criel
982da4aa77 more cleanup 2025-10-22 15:16:53 -07:00
Reinier Criel
fbb7e0f95f Add tests 2025-10-22 14:51:44 -07:00
Reinier Criel
1f707c1e13 Add cert 2025-10-22 09:43:40 -07:00
Reinier Criel
246071363a Merge branch 'main' into feature/pypi 2025-10-22 07:15:17 -07:00
Reinier Criel
8b9ffc28ed Some cleanup 2025-10-22 07:04:35 -07:00
Reinier Criel
f086aeb2be Skeleton 2025-10-22 06:59:32 -07:00
Sander Declerck
2e1ee0dfa4
Merge pull request #119 from AikidoSec/proxy-unit-tests
Add tests for the proxy
2025-10-22 15:47:16 +02:00
Sander Declerck
f4cdf91fc9
Add tests for the proxy 2025-10-22 15:41:33 +02:00
Reinier Criel
d0f2edec0a Skeleton 2025-10-21 15:25:12 -07:00
bitterpanda
6a69eec342
Merge pull request #114 from AikidoSec/handle-package-without-version
Fix crash when a package does not contain a version (retracted packages)
2025-10-21 15:57:11 +02:00
Sander Declerck
1ded3899b0
Commit new tests 2025-10-21 14:56:46 +02:00
Sander Declerck
da865f855d
Fix crash when a package does not contain a version (retracted packages) 2025-10-21 14:29:17 +02:00
Sander Declerck
b935f8d4f4
Merge pull request #105 from AikidoSec/kill-dry-run
Remove dry-run scanner for npm, relying on the proxy to block maliscious package downloads instead
2025-10-15 12:04:26 +02:00
bitterpanda
e123c0e019
Merge pull request #106 from AikidoSec/remove-abbrev-package
Remove abbrev package
2025-10-15 12:03:07 +02:00
bitterpanda
9cec5e4bc9
Merge pull request #108 from AikidoSec/proxy-http-requests
Allow the safe-chain to act as a regular http proxy too (besides the CONNECT tunneling implementation)
2025-10-15 12:02:40 +02:00
Sander Declerck
05354ba2f0
Add some more comments on why http / https is handled in different code paths 2025-10-15 11:56:03 +02:00
Sander Declerck
3e8ce13db5
Move generated abbrevs to a separate file 2025-10-15 11:51:56 +02:00
Sander Declerck
37ef3e187b
Further cleanup 2025-10-15 09:25:24 +02:00
Sander Declerck
fce7550609
Cleanup debugging code from test again 2025-10-15 09:21:23 +02:00
Sander Declerck
056a1963e3
Remove test again 2025-10-15 09:18:11 +02:00
Sander Declerck
3aec473755
Without safe-chain 2025-10-15 08:50:13 +02:00
Sander Declerck
1f2d4e86c7
Add registry to localhost again 2025-10-15 07:54:35 +02:00
Sander Declerck
1a8d58889c
Try again 2025-10-15 07:50:56 +02:00
Sander Declerck
b4f7d84563
Run npm install command 2025-10-15 07:50:13 +02:00
Sander Declerck
24bda852d0
Redo test - start simple 2025-10-15 07:42:16 +02:00
Sander Declerck
b567016ddd
Simplify test 2025-10-14 16:11:34 +02:00
Sander Declerck
d35a4ca357
Change config location 2025-10-14 16:05:39 +02:00
Sander Declerck
93223fe640
Try more config 2025-10-14 16:00:31 +02:00
Sander Declerck
7ae4d3bc8d
Try some more config 2025-10-14 15:59:43 +02:00
Sander Declerck
23bce71356
Fix config 2 2025-10-14 15:40:08 +02:00
Sander Declerck
b794b293d1
Fix config 2025-10-14 15:32:13 +02:00
Sander Declerck
4c76242d44
More config 2025-10-14 15:25:10 +02:00
Sander Declerck
dfdce18c8d
Fix config 2025-10-14 15:23:40 +02:00
Sander Declerck
bfe5820d0f
Log even more 2025-10-14 15:16:57 +02:00
Sander Declerck
daf69964f2
Test without safe-chain 2025-10-14 15:00:00 +02:00
Sander Declerck
ee82134c19
Proxyres on close and end 2025-10-14 14:54:58 +02:00
Sander Declerck
a2d05b0cf0
More logs 2025-10-14 14:18:33 +02:00
Sander Declerck
35beeb55b0
Curl url with npm package 2025-10-14 14:10:23 +02:00
Sander Declerck
f655e8cfcb
Change command to install through registry. 2025-10-14 13:52:28 +02:00
Sander Declerck
37585e8073
Add more logs, handle verdaccio not starting better 2025-10-14 13:44:49 +02:00
Sander Declerck
c50eac977b
Throw when verdaccio did not start 2025-10-14 13:34:47 +02:00
Sander Declerck
b6c31e1a5a
Increase time to start verdaccio 2025-10-14 13:30:06 +02:00
Sander Declerck
2968960b41
Cleanup registryProxy, increase timeout on DockerTestContainer 2025-10-14 13:22:58 +02:00
Sander Declerck
f4933b08d0
Add log to diagnose e2e tests 2025-10-14 13:15:14 +02:00
Sander Declerck
d2c155afee
Add e2e test for registry over http 2025-10-14 12:55:56 +02:00
Sander Declerck
8ed2330a3c
Allow the safe-chain to act as a regular http proxy too (besides the CONNECT tunneling implementation) 2025-10-13 15:49:42 +02:00
Sander Declerck
4be412483e
Also push new lockfile 2025-10-10 16:20:56 +02:00
Sander Declerck
ea92ea0731
Remove abbrev package 2025-10-10 16:19:38 +02:00
Sander Declerck
8aebb1b96b
Remove dry-run scanner for npm, relying on the proxy to block maliscious package downloads instead 2025-10-10 16:18:43 +02:00
Sander Declerck
5eedbfb57f
Merge pull request #104 from AikidoSec/safe-chain-version-command
Add command to get the safe-chain version
2025-10-10 15:47:29 +02:00
Sander Declerck
4fc33d2387
Add command to get the safe-chain version 2025-10-10 15:34:33 +02:00
Sander Declerck
dc4352bffb
Merge pull request #99 from AikidoSec/remove-sync
Remove `safeSpawnSync` (unused)
2025-10-10 15:04:39 +02:00
Hans Ott
2fa14b82f3 Simplify tests 2025-10-10 14:57:28 +02:00
Sander Declerck
831621323b
Merge pull request #101 from AikidoSec/oxlint
Use oxlint instead of eslint
2025-10-10 14:54:54 +02:00
Sander Declerck
a2d9469761
Merge pull request #103 from AikidoSec/handle-socket-errors
Listen to error events on sockets
2025-10-10 14:08:27 +02:00
Sander Declerck
a377fd6caa
Listen to error events on sockets 2025-10-10 13:55:39 +02:00
Hans Ott
5518846e96
Update packages/safe-chain/package.json
Co-authored-by: Timo Kössler <info@timokoessler.de>
2025-10-10 11:45:34 +02:00
Hans Ott
5e08461859
Add $schema reference for autocompletion
Co-authored-by: Timo Kössler <info@timokoessler.de>
2025-10-10 11:41:42 +02:00
Hans Ott
41ab4b1edb Use oxlint instead of eslint
- Less dev dependencies
- Much faster
- More helpful output
- More sane defaults
- Easier config
2025-10-09 18:03:45 +02:00
Hans Ott
459f3a5b14 Remove unused import 2025-10-09 17:35:29 +02:00
Sander Declerck
7603a29182
Merge pull request #98 from AikidoSec/yarn-tls-errors
Don't set YARN_HTTPS_CA_FILE_PATH, it ignores all system CAs
2025-10-09 16:57:53 +02:00
Hans Ott
36213a52f1 Run unit tests on windows 2025-10-09 16:52:05 +02:00
Hans Ott
0afea0eed6 Remove safeSpawnSync (unused) 2025-10-09 16:44:55 +02:00
Sander Declerck
ad7e94dac4
Add unit tests for yarn environment variables 2025-10-09 15:35:43 +02:00
Sander Declerck
d5620b2d12
Don't set YARN_HTTPS_CA_FILE_PATH, it ignores all system CAs 2025-10-09 14:58:06 +02:00
Sander Declerck
662b26a2d5
Merge pull request #95 from AikidoSec/proxy-socket-check-if-writable
Check if a socket is writable before writing to it
2025-10-08 19:51:57 +02:00
Sander Declerck
abc0add350
Downgrade safe-chain in e2e tests to 1.0.24 2025-10-08 19:43:11 +02:00
Sander Declerck
219a189993
Check if a socket is writable before writing to it 2025-10-08 19:32:25 +02:00
Sander Declerck
486a4b8f68
Escape special chars in shell scripts 2025-10-06 16:25:12 +02:00
223 changed files with 25077 additions and 6913 deletions

View file

@ -4,9 +4,135 @@ on:
push:
tags:
- "*"
release:
types: [published]
permissions:
id-token: write
contents: write
jobs:
build:
set-version:
name: Set version number
if: github.event_name == 'push'
runs-on: open-source-releaser
outputs:
version: ${{ steps.get_version.outputs.tag }}
steps:
- name: Set version number
id: get_version
run: |
version="${{ github.ref_name }}"
echo "tag=$version" >> $GITHUB_OUTPUT
create-binaries:
if: github.event_name == 'push'
needs: set-version
uses: ./.github/workflows/create-artifact.yml
with:
version: ${{ needs.set-version.outputs.version }}
publish-binaries:
name: Publish to GitHub release
if: github.event_name == 'push'
needs: [set-version, create-binaries]
runs-on: open-source-releaser
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download all binary artifacts
uses: actions/download-artifact@v4
with:
path: binaries/
pattern: safe-chain-*
merge-multiple: false
- name: Rename binaries to include platform and architecture
run: |
mkdir release-artifacts
mv binaries/safe-chain-macos-x64/safe-chain release-artifacts/safe-chain-macos-x64
mv binaries/safe-chain-macos-arm64/safe-chain release-artifacts/safe-chain-macos-arm64
mv binaries/safe-chain-linux-x64/safe-chain release-artifacts/safe-chain-linux-x64
mv binaries/safe-chain-linux-arm64/safe-chain release-artifacts/safe-chain-linux-arm64
mv binaries/safe-chain-linuxstatic-x64/safe-chain release-artifacts/safe-chain-linuxstatic-x64
mv binaries/safe-chain-linuxstatic-arm64/safe-chain release-artifacts/safe-chain-linuxstatic-arm64
mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe
mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe
- name: Move install scripts and hard-code version and checksums
env:
VERSION: ${{ needs.set-version.outputs.version }}
run: |
SHA_MACOS_X64=$(sha256sum release-artifacts/safe-chain-macos-x64 | awk '{print $1}')
SHA_MACOS_ARM64=$(sha256sum release-artifacts/safe-chain-macos-arm64 | awk '{print $1}')
SHA_LINUX_X64=$(sha256sum release-artifacts/safe-chain-linux-x64 | awk '{print $1}')
SHA_LINUX_ARM64=$(sha256sum release-artifacts/safe-chain-linux-arm64 | awk '{print $1}')
SHA_LINUXSTATIC_X64=$(sha256sum release-artifacts/safe-chain-linuxstatic-x64 | awk '{print $1}')
SHA_LINUXSTATIC_ARM64=$(sha256sum release-artifacts/safe-chain-linuxstatic-arm64 | awk '{print $1}')
SHA_WIN_X64=$(sha256sum release-artifacts/safe-chain-win-x64.exe | awk '{print $1}')
SHA_WIN_ARM64=$(sha256sum release-artifacts/safe-chain-win-arm64.exe | awk '{print $1}')
sed \
-e "s/\$(fetch_latest_version)/${VERSION}/" \
-e "s|^SHA256_MACOS_X64=\"\"|SHA256_MACOS_X64=\"${SHA_MACOS_X64}\"|" \
-e "s|^SHA256_MACOS_ARM64=\"\"|SHA256_MACOS_ARM64=\"${SHA_MACOS_ARM64}\"|" \
-e "s|^SHA256_LINUX_X64=\"\"|SHA256_LINUX_X64=\"${SHA_LINUX_X64}\"|" \
-e "s|^SHA256_LINUX_ARM64=\"\"|SHA256_LINUX_ARM64=\"${SHA_LINUX_ARM64}\"|" \
-e "s|^SHA256_LINUXSTATIC_X64=\"\"|SHA256_LINUXSTATIC_X64=\"${SHA_LINUXSTATIC_X64}\"|" \
-e "s|^SHA256_LINUXSTATIC_ARM64=\"\"|SHA256_LINUXSTATIC_ARM64=\"${SHA_LINUXSTATIC_ARM64}\"|" \
-e "s|^SHA256_WIN_X64=\"\"|SHA256_WIN_X64=\"${SHA_WIN_X64}\"|" \
-e "s|^SHA256_WIN_ARM64=\"\"|SHA256_WIN_ARM64=\"${SHA_WIN_ARM64}\"|" \
install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
sed \
-e "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" \
-e "s|^\$SHA256_MACOS_X64 = \"\"|\$SHA256_MACOS_X64 = \"${SHA_MACOS_X64}\"|" \
-e "s|^\$SHA256_MACOS_ARM64 = \"\"|\$SHA256_MACOS_ARM64 = \"${SHA_MACOS_ARM64}\"|" \
-e "s|^\$SHA256_LINUX_X64 = \"\"|\$SHA256_LINUX_X64 = \"${SHA_LINUX_X64}\"|" \
-e "s|^\$SHA256_LINUX_ARM64 = \"\"|\$SHA256_LINUX_ARM64 = \"${SHA_LINUX_ARM64}\"|" \
-e "s|^\$SHA256_LINUXSTATIC_X64 = \"\"|\$SHA256_LINUXSTATIC_X64 = \"${SHA_LINUXSTATIC_X64}\"|" \
-e "s|^\$SHA256_LINUXSTATIC_ARM64 = \"\"|\$SHA256_LINUXSTATIC_ARM64 = \"${SHA_LINUXSTATIC_ARM64}\"|" \
-e "s|^\$SHA256_WIN_X64 = \"\"|\$SHA256_WIN_X64 = \"${SHA_WIN_X64}\"|" \
-e "s|^\$SHA256_WIN_ARM64 = \"\"|\$SHA256_WIN_ARM64 = \"${SHA_WIN_ARM64}\"|" \
install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1
cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1
cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh
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
- name: Create draft release and upload assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.set-version.outputs.version }}
run: |
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-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
publish-npm:
name: Publish to npm
if: github.event_name == 'release'
runs-on: ubuntu-latest
steps:
@ -18,22 +144,12 @@ jobs:
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org/"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
- name: Setup safe-chain
run: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
- name: Set version number
id: get_version
run: |
version="${{ github.ref_name }}"
echo "tag=$version" >> $GITHUB_OUTPUT
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
run: npm --no-git-tag-version version ${{ steps.get_version.outputs.tag }} --workspace=packages/safe-chain
run: npm --no-git-tag-version version ${{ github.event.release.tag_name }} --workspace=packages/safe-chain
- name: Install dependencies
run: npm ci
@ -46,10 +162,15 @@ jobs:
cp README.md packages/safe-chain/
cp LICENSE packages/safe-chain/
cp -r docs packages/safe-chain/
cp npm-shrinkwrap.json packages/safe-chain/
- name: Publish to npm
run: |
echo "Publishing version ${{ steps.get_version.outputs.tag }} to NPM"
npm publish --workspace=packages/safe-chain --access public
env:
NPM_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
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
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}\"}"

94
.github/workflows/create-artifact.yml vendored Normal file
View file

@ -0,0 +1,94 @@
name: Create binaries
on:
pull_request:
workflow_call:
inputs:
version:
description: "Version to set in package.json"
required: false
type: string
jobs:
create-binaries:
name: Create binary for ${{ matrix.os }}-${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- os: macos
arch: x64
runner: macos-15-intel
target: node20-macos-x64
extension: ""
- os: macos
arch: arm64
runner: macos-latest
target: node20-macos-arm64
extension: ""
- os: linux
arch: x64
runner: ubuntu-latest
target: node20-linux-x64
extension: ""
- os: linux
arch: arm64
runner: ubuntu-24.04-arm
target: node20-linux-arm64
extension: ""
- os: linuxstatic
arch: x64
runner: ubuntu-latest
target: node20-linuxstatic-x64
extension: ""
- os: linuxstatic
arch: arm64
runner: ubuntu-24.04-arm
target: node20-linuxstatic-arm64
extension: ""
- os: win
arch: x64
runner: windows-latest
target: node20-win-x64
extension: ".exe"
- os: win
arch: arm64
runner: windows-11-arm
target: node20-win-arm64
extension: ".exe"
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "20.x"
- name: Setup safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
shell: bash
- name: Install dependencies
run: npm ci --ignore-scripts
- name: Set the version in safe-chain package
if: inputs.version != ''
env:
VERSION: ${{ inputs.version }}
shell: bash
run: npm --no-git-tag-version version $VERSION --workspace=packages/safe-chain --ignore-scripts
- name: Create binary
run: |
node build.js ${{ matrix.target }}
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: safe-chain-${{ matrix.os }}-${{ matrix.arch }}
path: dist/*

View file

@ -6,7 +6,12 @@ jobs:
unit-test:
name: Run unit tests and linting
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout code
@ -18,24 +23,28 @@ jobs:
node-version: "lts/*"
- name: Setup safe-chain
run: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
shell: bash
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Run unit tests
run: npm test
- name: Run ESLint
- name: Run linting
run: npm run lint
- name: Type check
run: npm run typecheck --workspace=packages/safe-chain
- name: Create package tarball
if: matrix.os == 'ubuntu-latest'
run: npm pack --workspace=packages/safe-chain
- name: Upload package tarball
uses: actions/upload-artifact@v4
if: matrix.os == 'ubuntu-latest'
with:
name: safe-chain-package
path: aikidosec-safe-chain-*.tgz
@ -68,7 +77,7 @@ jobs:
- node_version: "20"
npm_version: "9.0.0"
yarn_version: "latest"
pnpm_version: "latest"
pnpm_version: "10.0.0"
# Version pinning scenario
- node_version: "22"
npm_version: "10.2.0"
@ -78,17 +87,12 @@ jobs:
- node_version: "18"
npm_version: "latest"
yarn_version: "latest"
pnpm_version: "latest"
pnpm_version: "10.0.0"
# Future compatibility (becomes LTS October 2025)
- node_version: "24"
npm_version: "latest"
yarn_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:
- name: Checkout code
@ -100,9 +104,7 @@ jobs:
node-version: "lts/*"
- name: Setup safe-chain
run: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: Install dependencies (root)
run: npm ci

9
.gitignore vendored
View file

@ -143,4 +143,11 @@ vite.config.ts.timestamp-*
# AI
Claude.md
.claude
.reference
.reference
# Build files
build/
dist/
# Jetbrains IDEs
.idea/**

30
.oxlintrc.json Normal file
View file

@ -0,0 +1,30 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"node",
"promise",
"eslint",
"unicorn",
"oxc",
"import"
],
"env": {
"browser": false,
"node": true
},
"rules": {
"eslint/no-console": "error",
"eslint/no-empty": "error",
"eslint/no-undef": "error"
},
"overrides": [
{
"files": [
"*.spec.js"
],
"rules": {
"eslint/no-console": "off"
}
}
]
}

542
README.md
View file

@ -1,50 +1,146 @@
![Aikido Safe Chain](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/banner.svg)
# Aikido Safe Chain
The Aikido Safe Chain **prevents developers from installing malware** on their workstations through npm, npx, yarn, pnpm, pnpx, bun, and bunx. It's **free** to use and does not require any token.
[![NPM Version](https://img.shields.io/npm/v/%40aikidosec%2Fsafe-chain?style=flat-square)](https://www.npmjs.com/package/@aikidosec/safe-chain)
[![NPM Downloads](https://img.shields.io/npm/dw/%40aikidosec%2Fsafe-chain?style=flat-square)](https://www.npmjs.com/package/@aikidosec/safe-chain)
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/), and [bunx](https://bun.sh/docs/cli/bunx) 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, or bunx from downloading or running the malware.
- ✅ **Block malware on developer laptops and CI/CD**
- ✅ **Supports npm and PyPI** more package managers coming
- ✅ **Blocks packages newer than 48 hours** without breaking your build
- ✅ **Tokenless, free, no build data shared**
![demo](./docs/safe-package-manager-demo.png)
## Need protection beyond npm & PyPI?
Aikido Safe Chain works on Node.js version 18 and above and supports the following package managers:
[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.
- ✅ **npm**
- ✅ **npx**
- ✅ **yarn**
- ✅ **pnpm**
- ✅ **pnpx**
- ✅ **bun**
- ✅ **bunx**
Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru).
---
Aikido Safe Chain supports the following package managers:
- 📦 **npm**
- 📦 **npx**
- 📦 **yarn**
- 📦 **pnpm**
- 📦 **pnpx**
- 📦 **rush**
- 📦 **rushx**
- 📦 **bun**
- 📦 **bunx**
- 📦 **pip**
- 📦 **pip3**
- 📦 **uv**
- 📦 **poetry**
- 📦 **uvx**
- 📦 **pipx**
- 📦 **pdm**
# Usage
![Aikido Safe Chain demo](https://raw.githubusercontent.com/AikidoSec/safe-chain/main/docs/safe-package-manager-demo.gif)
## Installation
Installing the Aikido Safe Chain is easy. You just need 3 simple steps:
Installing the Aikido Safe Chain is easy with our one-line installer.
### Unix/Linux/macOS
```shell
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh
```
### Windows (PowerShell)
```powershell
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1" -UseBasicParsing)
```
### Pinning to a specific version
To install a specific version instead of the latest, replace `latest` with the version number in the URL (available from version 1.3.2 onwards):
**Unix/Linux/macOS:**
```shell
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.sh | sh
```
**Windows (PowerShell):**
```powershell
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/download/x.x.x/install-safe-chain.ps1" -UseBasicParsing)
```
You can find all available versions on the [releases page](https://github.com/AikidoSec/safe-chain/releases).
### Verify the installation
1. **❗Restart your terminal** to start using the Aikido Safe Chain.
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, 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:
1. **Install the Aikido Safe Chain package globally** using npm:
```shell
npm install -g @aikidosec/safe-chain
npm safe-chain-verify
pnpm safe-chain-verify
pip safe-chain-verify
uv safe-chain-verify
# Any other supported package manager: {packagemanager} safe-chain-verify
```
2. **Setup the shell integration** by running:
```shell
safe-chain setup
```
3. **❗Restart your terminal** to start using the Aikido Safe Chain.
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, and bunx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
4. **Verify the installation** by running:
- The output should display "OK: Safe-chain works!" confirming that Aikido Safe Chain is properly installed and running.
3. **(Optional) Test malware blocking** by attempting to install a test package:
For JavaScript/Node.js:
```shell
npm install safe-chain-test
```
- The output should show that Aikido Safe Chain is blocking the installation of this package as it is flagged as malware.
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, or `bunx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. If any malware is detected, it will prompt you to exit the command.
For Python:
```shell
pip3 install safe-chain-pi-test
```
- 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`, `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:
```shell
safe-chain --version
```
## How it works
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry. When you run npm, npx, yarn, pnpm, pnpx, bun, or bunx 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.
### Malware Blocking
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, and bunx commands. It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
The Aikido Safe Chain 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
Safe Chain applies minimum package age checks to supported ecosystems.
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
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx, pdm). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
- ✅ **Bash**
- ✅ **Zsh**
@ -52,57 +148,238 @@ The Aikido Safe Chain integrates with your shell to provide a seamless experienc
- ✅ **PowerShell**
- ✅ **PowerShell Core**
More information about the shell integration can be found in the [shell integration documentation](docs/shell-integration.md).
More information about the shell integration can be found in the [shell integration documentation](https://github.com/AikidoSec/safe-chain/blob/main/docs/shell-integration.md).
## Uninstallation
To uninstall the Aikido Safe Chain, you can run the following command:
To uninstall the Aikido Safe Chain, use our one-line uninstaller:
1. **Remove all aliases from your shell** by running:
```shell
safe-chain teardown
```
2. **Uninstall the Aikido Safe Chain package** using npm:
```shell
npm uninstall -g @aikidosec/safe-chain
```
3. **❗Restart your terminal** to remove the aliases.
### Unix/Linux/macOS
```shell
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.sh | sh
```
### Windows (PowerShell)
```powershell
iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/uninstall-safe-chain.ps1" -UseBasicParsing)
```
**❗Restart your terminal** after uninstalling to ensure all aliases are removed.
# Configuration
## Malware Action
## Logging
You can control how Aikido Safe Chain responds when malware is detected using the `--safe-chain-malware-action` flag:
You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag or the `SAFE_CHAIN_LOGGING` environment variable.
- `--safe-chain-malware-action=block` (**default**) - Automatically blocks installation and exits with an error when malware is detected
- `--safe-chain-malware-action=prompt` - Prompts the user to decide whether to continue despite the malware detection
### Configuration Options
Example usage:
You can set the logging level through multiple sources (in order of priority):
1. **CLI Argument** (highest priority):
- `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit.
```shell
npm install express --safe-chain-logging=silent
```
- `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes.
```shell
npm install express --safe-chain-logging=verbose
```
2. **Environment Variable**:
```shell
export SAFE_CHAIN_LOGGING=verbose
npm install express
```
Valid values: `silent`, `normal`, `verbose`
This is useful for setting a default logging level for all package manager commands in your terminal session or CI/CD environment.
## 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.
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
You can set the minimum package age through multiple sources (in order of priority):
1. **CLI Argument** (highest priority):
```shell
npm install express --safe-chain-minimum-package-age-hours=48
```
2. **Environment Variable**:
```shell
export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS=48
npm install express
```
3. **Config File** (`~/.safe-chain/config.json`):
```json
{
"minimumPackageAgeHours": 48
}
```
### Excluding Packages
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
npm install suspicious-package --safe-chain-malware-action=prompt
export SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*"
```
```json
{
"npm": {
"minimumPackageAgeExclusions": ["@aikidosec/*"]
},
"pip": {
"minimumPackageAgeExclusions": ["requests"]
}
}
```
## Custom Registries
Configure Safe Chain to scan packages from custom or private registries.
Supported ecosystems:
- Node.js
- Python
### Configuration Options
You can set custom registries through environment variable or config file. Both sources are merged together.
1. **Environment Variable** (comma-separated):
```shell
export SAFE_CHAIN_NPM_CUSTOM_REGISTRIES="npm.company.com,registry.internal.net"
export SAFE_CHAIN_PIP_CUSTOM_REGISTRIES="pip.company.com,registry.internal.net"
```
2. **Config File** (`~/.safe-chain/config.json`):
```json
{
"npm": {
"customRegistries": ["npm.company.com", "registry.internal.net"]
},
"pip": {
"customRegistries": ["pip.company.com", "registry.internal.net"]
}
}
```
## 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
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.
For optimal protection in CI/CD environments, we recommend using **npm >= 10.4.0** as it provides full dependency tree scanning. Other package managers currently offer limited scanning of install command arguments only.
## Installation for CI/CD
## Setup
Use the `--ci` flag to automatically configure Aikido Safe Chain for CI/CD environments. This sets up executable shims in the PATH instead of shell aliases.
To use Aikido Safe Chain in CI/CD environments, run the following command after installing the package:
### Unix/Linux/macOS (GitHub Actions, Azure Pipelines, etc.)
```shell
safe-chain setup-ci
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
```
This automatically configures your CI environment to use Aikido Safe Chain for all package manager commands.
### Windows (Azure Pipelines, etc.)
```powershell
iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -ci"
```
## Supported Platforms
- ✅ **GitHub Actions**
- ✅ **Azure Pipelines**
- ✅ **CircleCI**
- ✅ **Jenkins**
- ✅ **Bitbucket Pipelines**
- ✅ **GitLab Pipelines**
## GitHub Actions Example
@ -113,14 +390,11 @@ This automatically configures your CI environment to use Aikido Safe Chain for a
node-version: "22"
cache: "npm"
- name: Setup safe-chain
run: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
- name: Install safe-chain
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- name: Install dependencies
run: |
npm ci
run: npm ci
```
## Azure DevOps Example
@ -131,14 +405,162 @@ This automatically configures your CI environment to use Aikido Safe Chain for a
versionSpec: "22.x"
displayName: "Install Node.js"
- script: |
npm i -g @aikidosec/safe-chain
safe-chain setup-ci
displayName: "Install safe chain"
- script: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
displayName: "Install safe-chain"
- script: |
npm ci
displayName: "npm install and build"
- script: npm ci
displayName: "Install dependencies"
```
## CircleCI Example
```yaml
version: 2.1
jobs:
build:
docker:
- image: cimg/node:lts
steps:
- checkout
- run: |
curl -fsSL https://raw.githubusercontent.com/AikidoSec/safe-chain/main/install-scripts/install-safe-chain.sh | sh -s -- --ci
- run: npm ci
workflows:
build_and_test:
jobs:
- build
```
## Jenkins Example
Note: This assumes Node.js and npm are installed on the Jenkins agent.
```groovy
pipeline {
agent any
environment {
// Jenkins does not automatically persist PATH updates from setup-ci,
// 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}"
}
stages {
stage('Install safe-chain') {
steps {
sh '''
set -euo pipefail
# Install Safe Chain for CI
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
'''
}
}
stage('Install project dependencies etc...') {
steps {
sh '''
set -euo pipefail
npm ci
'''
}
}
}
}
```
## Bitbucket Pipelines Example
```yaml
image: node:22
steps:
- step:
name: Install
script:
- curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
- export PATH=~/.safe-chain/shims:~/.safe-chain/bin:$PATH
- npm ci
```
After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection.
## GitLab Pipelines Example
To add safe-chain in GitLab pipelines, you need to install it in the image running the pipeline. This can be done by:
1. Define a dockerfile to run your build
```dockerfile
FROM node:lts
# Install safe-chain
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
# 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}"
```
2. Build the Docker image in your CI pipeline
```yaml
build-image:
stage: build-image
image: docker:latest
services:
- docker:dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:latest
```
3. Use the image in your pipeline:
```yaml
npm-ci:
stage: install
image: $CI_REGISTRY_IMAGE:latest
script:
- npm ci
```
The full pipeline for this example looks like this:
```yaml
stages:
- build-image
- install
build-image:
stage: build-image
image: docker:latest
services:
- docker:dind
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:latest .
- docker push $CI_REGISTRY_IMAGE:latest
npm-ci:
stage: install
image: $CI_REGISTRY_IMAGE:latest
script:
- npm ci
```
# Troubleshooting
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)

139
build.js Normal file
View file

@ -0,0 +1,139 @@
import { build } from "esbuild";
import { mkdir, cp, rm, readFile, writeFile, stat } from "node:fs/promises";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
const target = process.argv[2];
if (!target) {
// eslint-disable-next-line no-console
console.error("Usage: node build.js <target>");
// eslint-disable-next-line no-console
console.error("Example: node build.js node22-macos-arm64");
process.exit(1);
}
(async function main() {
const startBuildTime = performance.now();
await clearOutputFolder();
console.log("- Cleared output folder ✅")
// Esbuild creates a single safe-chain.cjs with all dependencies included
await bundleSafeChain();
console.log("- Bundled safe-chain into safe-chain.cjs (es-build) ✅")
// Copy assets that need to be included in the binary
// - All shell scripts that are used to setup safe-chain
// - Certifi because it contains static root certs for Python
// - Package.json for its metadata (package name, version, ...)
await copyShellScripts();
await copyCertifi();
await copyAndModifyPackageJson();
console.log("- Copied auxiliary resources (shell, package.json,...) ✅")
// Creates a single binary with safe-chain.cjs and the copied assets
await buildSafeChainBinary(target);
console.log(`- Built safe-chain binary for ${target} (pkg) ✅`)
const totalBuildTime = (performance.now() - startBuildTime)/1000;
const totalSizeInMb = (await stat("./dist/safe-chain" + (process.platform === "win32" ? ".exe" : ""))).size / (1024*1024);
console.log(`🏁 Finished build in ${totalBuildTime.toFixed(2)}s, total build size: ${totalSizeInMb.toFixed(2)}MB`);
})();
async function clearOutputFolder() {
await rm("./build", { recursive: true, force: true });
await mkdir("./build");
}
async function bundleSafeChain() {
await build({
entryPoints: ["./packages/safe-chain/bin/safe-chain.js"],
bundle: true,
platform: "node",
target: "node24",
outfile: "./build/bin/safe-chain.cjs",
external: ["certifi"],
});
let bundledContent = await readFile("./build/bin/safe-chain.cjs", "utf-8");
await writeFile("./build/bin/safe-chain.cjs", bundledContent);
}
async function copyShellScripts() {
await mkdir("./build/bin/startup-scripts", { recursive: true });
await cp(
"./packages/safe-chain/src/shell-integration/startup-scripts/",
"./build/bin/startup-scripts",
{ recursive: true }
);
await mkdir("./build/bin/path-wrappers", { recursive: true });
await cp(
"./packages/safe-chain/src/shell-integration/path-wrappers/",
"./build/bin/path-wrappers",
{ recursive: true }
);
}
async function copyCertifi() {
await mkdir("./build/node_modules/certifi", { recursive: true });
await cp("./node_modules/certifi/", "./build/node_modules/certifi", {
recursive: true,
});
}
async function copyAndModifyPackageJson() {
const packageJsonContent = await readFile(
"./packages/safe-chain/package.json",
"utf-8"
);
const packageJson = JSON.parse(packageJsonContent);
delete packageJson.main;
delete packageJson.scripts;
delete packageJson.exports;
delete packageJson.dependencies;
delete packageJson.devDependencies;
packageJson.bin = {
"safe-chain": "bin/safe-chain.cjs",
};
packageJson.type = "commonjs";
packageJson.pkg = {
outputPath: "dist",
assets: [
"node_modules/certifi/**/*",
"bin/startup-scripts/**/*",
"bin/path-wrappers/**/*",
],
};
await writeFile("./build/package.json", JSON.stringify(packageJson, null, 2));
return packageJson;
}
function buildSafeChainBinary(target) {
return new Promise((promiseResolve, reject) => {
// Use .cmd on Windows, resolve to absolute path for cross-platform compatibility
const pkgBin = process.platform === "win32"
? resolve("node_modules/.bin/pkg.cmd")
: resolve("node_modules/.bin/pkg");
let pkgArgs = [];
pkgArgs = pkgArgs.concat(["./build/package.json", "-t", target]);
const pkg = spawn(pkgBin, pkgArgs, {
stdio: "inherit",
shell: true,
});
pkg.on("close", (code) => {
if (code !== 0) {
reject(new Error(`pkg process exited with code ${code}`));
} else {
promiseResolve();
}
});
});
}

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.

151
docs/banner.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

@ -2,7 +2,7 @@
## Overview
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`) with Aikido's security scanning functionality. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
## Supported Shells
@ -28,7 +28,8 @@ This command:
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
- Detects all supported shells on your system
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx`
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx`
- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
@ -77,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts:
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
- Make sure Aikido Safe Chain is properly installed on your system
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, and `aikido-bunx` commands exist
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-rush`, `aikido-rushx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist
- Check that these commands are in your system's PATH
### Manual Verification
@ -120,4 +121,29 @@ npm() {
}
```
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, and `bunx` 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:
```bash
# Example for Bash/Zsh
python() {
if [[ "$1" == "-m" && "$2" == pip* ]]; then
local mod="$2"; shift 2
if [[ "$mod" == "pip3" ]]; then aikido-pip3 "$@"; else aikido-pip "$@"; fi
else
command python "$@"
fi
}
python3() {
if [[ "$1" == "-m" && "$2" == pip* ]]; then
local mod="$2"; shift 2
if [[ "$mod" == "pip3" ]]; then aikido-pip3 "$@"; else aikido-pip "$@"; fi
else
command python3 "$@"
fi
}
```
Limitations: these only apply when invoking `python`/`python3` by name. Absolute paths (e.g., `/usr/bin/python -m pip`) bypass shell functions.

298
docs/troubleshooting.md Normal file
View file

@ -0,0 +1,298 @@
# Troubleshooting
This guide helps you diagnose and resolve common issues with Aikido Safe Chain.
## Verification & Diagnostics
**Check Installation**
```bash
# Check version
safe-chain --version
```
**Verify Shell Integration**
Run the verification command for your package manager:
```bash
npm safe-chain-verify
pnpm safe-chain-verify
```
```
Expected output: `OK: Safe-chain works!`
```
**Test Malware Blocking**
Verify that malware detection is working:
```
npm install safe-chain-test
```
These test packages are flagged as malware and should be blocked by Safe Chain.
**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below.
## Logging Options
Use logging flags or environment variables to get more information:
```bash
# Verbose mode - detailed diagnostic output for troubleshooting
npm install express --safe-chain-logging=verbose
# Or set it globally for all commands in your session
export SAFE_CHAIN_LOGGING=verbose
npm install express
# Silent mode - suppress all output except malware blocking
npm install express --safe-chain-logging=silent
```
## Common Issues
### Malware Not Being Blocked
**Symptom:** Test malware packages (like `safe-chain-test`) install successfully when they should be blocked
**Most Common Cause:** The package is cached in your package manager's local store
Safe-chain blocks malicious packages by intercepting network requests to package registries using its proxy.
When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy.
**Resolution Steps**
1) Clear your package manager's cache
```bash
# For npm
npm cache clean --force
# For pnpm
pnpm store prune
# For yarn (classic)
yarn cache clean
# For yarn (berry/v2+)
yarn cache clean --all
# For bun
bun pm cache rm
```
2) Clean local installation artifacts:
```bash
# 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
```
### Shell Aliases Not Working After Installation
**Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version
**First step:** Restart your terminal (most common fix)
**Verify it's working:**
```bash
type npm
```
Should show: `npm is a function`
**If still not working:**
Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`:
* Bash: `~/.bashrc`
* Zsh: `~/.zshrc`
* Fish: `~/.config/fish/config.fish`
* PowerShell: `$PROFILE`
### "Command Not Found: safe-chain"
**Symptom:** Binary not found in PATH
**First step:** Restart your terminal
**Check PATH:**
```bash
echo $PATH
```
Should include `~/.safe-chain/bin`
**If persists:** Re-run the installation script
### PowerShell Execution Policy Blocks Scripts (Windows)
**Symptom:** When opening PowerShell, you see an error like:
```
. : File C:\Users\<username>\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1 cannot be loaded because
running scripts is disabled on this system.
CategoryInfo : SecurityError: (:) [], PSSecurityException
FullyQualifiedErrorId : UnauthorizedAccess
```
**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile.
**Resolution**
1) Set the execution policy to allow local scripts
Open PowerShell as Administrator and run:
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
```
This allows:
* Local scripts (like safe-chain's) to run without signing
* Downloaded scripts to run only if signed by a trusted publisher
2) Restart PowerShell and verify the error is resolved.
> [!IMPORTANT]
> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability.
### Shell Aliases Persist After Uninstallation
**Symptom:** safe-chain commands still active after running uninstall script
**Steps**
1. Run `safe-chain teardown` (if binary still exists)
2. Restart your terminal
3. If still present, manually edit shell config files:
* Bash: `~/.bashrc`
* Zsh: `~/.zshrc`
* Fish: `~/.config/fish/config.fish`
* PowerShell: `$PROFILE`
4. Remove lines that source scripts from `~/.safe-chain/scripts/`
5. Restart terminal again
## Manual Verification Steps
### Check Installation Status
```bash
# Check installation location (helps identify if installed via npm or as standalone binary)
which safe-chain
# Verify binary exists
ls ~/.safe-chain/bin/safe-chain
# Check version
safe-chain --version
# Test shell integration
type npm
type pip
```
**Expected `which` output:**
* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users/<username>/.safe-chain/bin/safe-chain`
* npm global (outdated): path containing `node_modules` or nvm version paths
If `which` shows an npm installation, see Check for Conflicting Installations.
### Check Shell Integration
```bash
# Which shell you're using
echo $SHELL
# Check if startup file sources safe-chain
# For Bash:
grep safe-chain ~/.bashrc
# For Zsh:
grep safe-chain ~/.zshrc
# For Fish:
grep safe-chain ~/.config/fish/config.fish
# Verify scripts exist
ls ~/.safe-chain/scripts/
```
### Check for Conflicting Installations
> **Note:** The install/uninstall scripts automatically detect and remove conflicting installations, but you can manually check:
```bash
# Check npm global
npm list -g @aikidosec/safe-chain
# Check Volta
volta list safe-chain
# Check nvm (all versions)
for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do
nvm exec "$version" npm list -g @aikidosec/safe-chain 2>/dev/null && echo "Found in $version"
done
```
### Manual Cleanup
> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails.
#### Remove npm Global Installation
```bash
npm uninstall -g @aikidosec/safe-chain
```
#### Remove Volta Installation
```bash
volta uninstall @aikidosec/safe-chain
```
#### Remove nvm Installations (All Versions)
```bash
# Automated approach
for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do
nvm exec "$version" npm uninstall -g @aikidosec/safe-chain
done
# Or manual per version
nvm use <version>
npm uninstall -g @aikidosec/safe-chain
```
#### Clean Shell Configuration Files
Manually remove safe-chain entries from:
* Bash: `~/.bashrc`
* Zsh: `~/.zshrc`
* Fish: `~/.config/fish/config.fish`
* PowerShell: `$PROFILE`
Look for and remove:
* Lines sourcing from `~/.safe-chain/scripts/`
* Any safe-chain related function definitions
#### Remove Installation Directory
```bash
rm -rf ~/.safe-chain
```

View file

@ -1,26 +0,0 @@
import js from "@eslint/js";
import { defineConfig, globalIgnores } from "@eslint/config-helpers";
import globals from "globals";
import importPlugin from "eslint-plugin-import";
export default defineConfig([
{
files: ["**/*.{js,mjs,cjs,ts}"],
plugins: { js },
extends: ["js/recommended"],
},
{
files: ["**/*.{js,mjs,cjs,ts}"],
languageOptions: { globals: globals.node },
},
importPlugin.flatConfigs.recommended,
{
files: ["**/*.{js,mjs,cjs}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
rules: {},
},
globalIgnores(['test/e2e', 'node_modules']),
]);

View file

@ -0,0 +1,133 @@
#!/bin/sh
# Downloads and installs Aikido Endpoint Protection on macOS
#
# Usage: curl -fsSL <url> | sudo sh -s -- --token <TOKEN>
set -e # Exit on error
# Configuration
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.pkg"
DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe"
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Helper functions
info() {
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
exit 1
}
# Download file
download() {
url="$1"
dest="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$dest" || error "Failed to download from $url"
elif command -v wget >/dev/null 2>&1; then
wget -q "$url" -O "$dest" || error "Failed to download from $url"
else
error "Neither curl nor wget found. Please install one of them."
fi
}
# Verify SHA256 checksum
verify_checksum() {
file="$1"
expected="$2"
actual=$(shasum -a 256 "$file" | awk '{ print $1 }')
if [ "$actual" != "$expected" ]; then
error "Checksum verification failed. Expected: $expected, Got: $actual"
fi
info "Checksum verified successfully."
}
# Cleanup temporary files
cleanup() {
if [ -f "$PKG_FILE" ]; then
rm -f "$PKG_FILE"
fi
if [ -f "$TOKEN_FILE" ]; then
rm -f "$TOKEN_FILE"
fi
}
# Parse command-line arguments
parse_arguments() {
TOKEN=""
while [ $# -gt 0 ]; do
case "$1" in
--token)
if [ -z "${2:-}" ]; then
error "--token requires a value"
fi
TOKEN="$2"
shift 2
;;
*)
error "Unknown argument: $1"
;;
esac
done
}
# Main installation
main() {
parse_arguments "$@"
# 1. Check if we're running on macOS
if [ "$(uname -s)" != "Darwin" ]; then
error "This script is only supported on macOS."
fi
# Check if we're running as root
if [ "$(id -u)" -ne 0 ]; then
error "Root privileges required. Please re-run with sudo, e.g.: curl -fsSL <url> | sudo sh -s -- --token <TOKEN>"
fi
# Check if token is provided via command argument
if [ -z "$TOKEN" ]; then
error "Token is required. Pass it with --token <TOKEN> or enter it when prompted."
fi
# Validate token to prevent injection
case "$TOKEN" in
*[\"\'\;\`\$\ ]*)
error "Invalid token format. Token must not contain quotes, semicolons, backticks, dollar signs, or whitespace."
;;
esac
# 2. Download and verify checksum
PKG_FILE=$(mktemp /tmp/AikidoEndpoint.XXXXXX.pkg)
trap cleanup EXIT
info "Downloading Aikido Endpoint Protection..."
download "$INSTALL_URL" "$PKG_FILE"
info "Verifying checksum..."
verify_checksum "$PKG_FILE" "$DOWNLOAD_SHA256"
# 3. Write token to file for the installer
printf "%s" "$TOKEN" > "$TOKEN_FILE"
# 4. Install the package
info "Installing Aikido Endpoint Protection..."
installer -pkg "$PKG_FILE" -target /
info "Aikido Endpoint Protection installed successfully!"
}
main "$@"

View file

@ -0,0 +1,100 @@
# Downloads and installs Aikido Endpoint Protection on Windows
#
# Usage: iex "& { $(iwr '<url>' -UseBasicParsing) } -token <TOKEN>"
param(
[string]$token
)
# Configuration
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.msi"
$DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be"
# Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Helper functions
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Green
}
function Write-Error-Custom {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
exit 1
}
# Check if running as Administrator
function Test-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# Main installation
function Install-Endpoint {
# 1. Check if we're running as Administrator
if (-not (Test-Administrator)) {
Write-Error-Custom "Administrator privileges required. Please run this script in an elevated terminal (Run as Administrator)."
}
# Check if token is provided, prompt if not
if ([string]::IsNullOrWhiteSpace($token)) {
$token = Read-Host "Enter your Aikido endpoint token"
if ([string]::IsNullOrWhiteSpace($token)) {
Write-Error-Custom "Token is required. Pass it with -token <TOKEN> or enter it when prompted."
}
}
# Validate token to prevent command/property injection via msiexec
if ($token -match '[";`$\s]') {
Write-Error-Custom "Invalid token format. Token must not contain quotes, semicolons, backticks, dollar signs, or whitespace."
}
# 2. Download the .msi
$msiFile = Join-Path $env:TEMP "AikidoEndpoint-$([System.Guid]::NewGuid().ToString('N')).msi"
Write-Info "Downloading Aikido Endpoint Protection..."
try {
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $InstallUrl -OutFile $msiFile -UseBasicParsing
$ProgressPreference = 'Continue'
}
catch {
Write-Error-Custom "Failed to download from $InstallUrl : $_"
}
try {
# Verify SHA256 checksum
Write-Info "Verifying checksum..."
$actualHash = (Get-FileHash -Path $msiFile -Algorithm SHA256).Hash.ToLower()
if ($actualHash -ne $DownloadSha256) {
Write-Error-Custom "Checksum verification failed. Expected: $DownloadSha256, Got: $actualHash"
}
Write-Info "Checksum verified successfully."
# 3. Install the package with token passed as MSI property
Write-Info "Installing Aikido Endpoint Protection..."
$process = Start-Process -FilePath "msiexec" -ArgumentList "/i", "`"$msiFile`"", "/qn", "/norestart", "AIKIDO_TOKEN=$token" -Wait -PassThru
if ($process.ExitCode -ne 0) {
Write-Error-Custom "MSI installer failed (exit code: $($process.ExitCode))."
}
Write-Info "Aikido Endpoint Protection installed successfully!"
}
finally {
# Cleanup
if (Test-Path $msiFile) {
Remove-Item -Path $msiFile -Force -ErrorAction SilentlyContinue
}
}
}
# Run installation
try {
Install-Endpoint
}
catch {
Write-Error-Custom "Installation failed: $_"
}

View file

@ -0,0 +1,381 @@
# Downloads and installs safe-chain for Windows
#
# Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md
param(
[switch]$ci,
[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
$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"
# SHA256 checksums for release binaries.
# Empty in source; populated by the release pipeline.
# When empty (running from main), checksum verification is skipped.
# Non-Windows hashes are unused today (PS script is Windows-only) but baked in
# for future cross-platform support.
$SHA256_MACOS_X64 = ""
$SHA256_MACOS_ARM64 = ""
$SHA256_LINUX_X64 = ""
$SHA256_LINUX_ARM64 = ""
$SHA256_LINUXSTATIC_X64 = ""
$SHA256_LINUXSTATIC_ARM64 = ""
$SHA256_WIN_X64 = ""
$SHA256_WIN_ARM64 = ""
# Ensure TLS 1.2 is enabled for downloads
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# Helper functions
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Green
}
function Write-Warn {
param([string]$Message)
Write-Host "[WARN] $Message" -ForegroundColor Yellow
}
function Write-Error-Custom {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
exit 1
}
# Get currently installed version of safe-chain
function Get-InstalledVersion {
# Check if safe-chain command exists
if (-not (Get-Command safe-chain -ErrorAction SilentlyContinue)) {
return $null
}
try {
# Execute safe-chain -v and capture output
$output = & safe-chain -v 2>&1
# Extract version from "Current safe-chain version: X.Y.Z" output
if ($output -match "Current safe-chain version:\s*(.+)") {
return $matches[1].Trim()
}
return $null
}
catch {
return $null
}
}
# Check if the requested version is already installed
function Test-VersionInstalled {
param([string]$RequestedVersion)
$installedVersion = Get-InstalledVersion
if ([string]::IsNullOrWhiteSpace($installedVersion)) {
return $false
}
# Strip leading 'v' from versions if present for comparison
$requestedClean = $RequestedVersion -replace '^v', ''
$installedClean = $installedVersion -replace '^v', ''
return $requestedClean -eq $installedClean
}
# Fetch latest release version tag from GitHub
function Get-LatestVersion {
try {
$response = Invoke-RestMethod -Uri "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" -UseBasicParsing
$latestVersion = $response.tag_name
if ([string]::IsNullOrWhiteSpace($latestVersion)) {
Write-Error-Custom "Failed to fetch latest version from GitHub API. Please set SAFE_CHAIN_VERSION environment variable."
}
return $latestVersion
}
catch {
Write-Error-Custom "Failed to fetch latest version from GitHub API: $($_.Exception.Message). Please set SAFE_CHAIN_VERSION environment variable."
}
}
# Detect architecture
function Get-Architecture {
$arch = $env:PROCESSOR_ARCHITECTURE
switch ($arch) {
"AMD64" { return "x64" }
"ARM64" { return "arm64" }
default { Write-Error-Custom "Unsupported architecture: $arch" }
}
}
# 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
function Remove-NpmInstallation {
# Check if npm is available
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
return
}
# Check if safe-chain is installed as an npm global package
npm list -g @aikidosec/safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Detected npm global installation of @aikidosec/safe-chain"
Write-Info "Uninstalling npm version before installing binary version..."
npm uninstall -g @aikidosec/safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Successfully uninstalled npm version"
}
else {
Write-Warn "Failed to uninstall npm version automatically"
Write-Warn "Please run: npm uninstall -g @aikidosec/safe-chain"
}
}
}
# Check and uninstall Volta-managed package if present
function Remove-VoltaInstallation {
# Check if Volta is available
if (-not (Get-Command volta -ErrorAction SilentlyContinue)) {
return
}
# Volta manages global packages in its own directory
# Check if safe-chain is installed via Volta
volta list safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Detected Volta installation of @aikidosec/safe-chain"
Write-Info "Uninstalling Volta version before installing binary version..."
volta uninstall @aikidosec/safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Successfully uninstalled Volta version"
}
else {
Write-Warn "Failed to uninstall Volta version automatically"
Write-Warn "Please run: volta uninstall @aikidosec/safe-chain"
}
}
}
# Main installation
function Install-SafeChain {
Write-VersionDeprecationWarning
# Fetch latest version if VERSION is not set
if ([string]::IsNullOrWhiteSpace($Version)) {
Write-Info "Fetching latest release version..."
$Version = Get-LatestVersion
}
# Check if the requested version is already installed
if (Test-VersionInstalled -RequestedVersion $Version) {
Write-Info "safe-chain $Version is already installed"
return
}
# Build installation message
$installMsg = "Installing safe-chain $Version"
if ($ci) {
$installMsg += " in ci"
}
if ($includepython) {
Write-Warn "-includepython is deprecated and ignored. Python ecosystem is now included by default."
}
Write-Info $installMsg
# Check for existing safe-chain installation through npm or volta
Remove-NpmInstallation
Remove-VoltaInstallation
# Detect platform
$arch = Get-Architecture
$binaryName = Get-BinaryName -Architecture $arch
Write-Info "Detected architecture: $arch"
# Create installation directory
if (-not (Test-Path $InstallDir)) {
Write-Info "Creating installation directory: $InstallDir"
try {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
catch {
Write-Error-Custom "Failed to create directory $InstallDir : $_"
}
}
# Download binary
$downloadUrl = "$RepoUrl/releases/download/$Version/$binaryName"
$tempFile = Join-Path $InstallDir $binaryName
Write-Info "Downloading from: $downloadUrl"
try {
# Download with progress suppressed for cleaner output
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempFile -UseBasicParsing
$ProgressPreference = 'Continue'
}
catch {
Write-Error-Custom "Failed to download from $downloadUrl : $_"
}
$expectedSha = Get-ExpectedSha256 -Os "win" -Architecture $arch
Test-Checksum -File $tempFile -Expected $expectedSha
# Rename to final location
$finalFile = Join-Path $InstallDir "safe-chain.exe"
try {
# Remove existing file if present (Move-Item -Force doesn't overwrite)
if (Test-Path $finalFile) {
Remove-Item -Path $finalFile -Force
}
Move-Item -Path $tempFile -Destination $finalFile -Force
}
catch {
Write-Error-Custom "Failed to move binary to $finalFile : $_"
}
Write-Info "Binary installed to: $finalFile"
Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir
}
# Run installation
try {
Install-SafeChain
}
catch {
Write-Error-Custom "Installation failed: $_"
}

View file

@ -0,0 +1,509 @@
#!/bin/sh
# Downloads and installs safe-chain, depending on the operating system and architecture
#
# Usage with "curl -fsSL {url} | sh" --> See README.md
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
VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set
SAFE_CHAIN_BASE="${HOME}/.safe-chain"
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
REPO_URL="https://github.com/AikidoSec/safe-chain"
# SHA256 checksums for release binaries.
# Empty in source; populated by the release pipeline via sed.
# When empty (running from main), checksum verification is skipped.
SHA256_MACOS_X64=""
SHA256_MACOS_ARM64=""
SHA256_LINUX_X64=""
SHA256_LINUX_ARM64=""
SHA256_LINUXSTATIC_X64=""
SHA256_LINUXSTATIC_ARM64=""
SHA256_WIN_X64=""
SHA256_WIN_ARM64=""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Helper functions
info() {
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
warn() {
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
}
error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
exit 1
}
# Detect OS
# For legacy versions (when SAFE_CHAIN_VERSION is set), use 'linux' instead of 'linuxstatic'
detect_os() {
case "$(uname -s)" in
Linux*)
if [ -n "$SAFE_CHAIN_VERSION" ]; then
echo "linux"
else
echo "linuxstatic"
fi
;;
Darwin*) echo "macos" ;;
MINGW*|MSYS*|CYGWIN*) echo "win" ;;
*) error "Unsupported operating system: $(uname -s)" ;;
esac
}
# Detect architecture
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "x64" ;;
aarch64|arm64) echo "arm64" ;;
*) error "Unsupported architecture: $(uname -m)" ;;
esac
}
# Check if command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Get currently installed version of safe-chain
get_installed_version() {
if ! command_exists safe-chain; then
echo ""
return
fi
# Extract version from "Current safe-chain version: X.Y.Z" output
installed_version=$(safe-chain -v 2>/dev/null | grep "Current safe-chain version:" | sed -E 's/.*: (.*)/\1/')
echo "$installed_version"
}
# Check if the requested version is already installed
is_version_installed() {
requested_version="$1"
installed_version=$(get_installed_version)
if [ -z "$installed_version" ]; then
return 1 # Not installed
fi
# Strip leading 'v' from versions if present for comparison
requested_clean=$(echo "$requested_version" | sed 's/^v//')
installed_clean=$(echo "$installed_version" | sed 's/^v//')
if [ "$requested_clean" = "$installed_clean" ]; then
return 0 # Same version installed
else
return 1 # Different version installed
fi
}
# Fetch latest release version tag from GitHub
fetch_latest_version() {
# Try using GitHub API to get the latest release tag
if command_exists curl; then
latest_version=$(curl -fsSL "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
elif command_exists wget; then
latest_version=$(wget -qO- "https://api.github.com/repos/AikidoSec/safe-chain/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
else
error "Neither curl nor wget found. Please install one of them or set SAFE_CHAIN_VERSION environment variable."
fi
if [ -z "$latest_version" ]; then
error "Failed to fetch latest version from GitHub API. Please set SAFE_CHAIN_VERSION environment variable."
fi
echo "$latest_version"
}
# Returns the expected SHA256 for the detected platform, or empty if the
# release pipeline has not baked one in (i.e. running the source from main).
get_expected_sha256() {
os="$1"; arch="$2"
case "${os}-${arch}" in
macos-x64) echo "$SHA256_MACOS_X64" ;;
macos-arm64) echo "$SHA256_MACOS_ARM64" ;;
linux-x64) echo "$SHA256_LINUX_X64" ;;
linux-arm64) echo "$SHA256_LINUX_ARM64" ;;
linuxstatic-x64) echo "$SHA256_LINUXSTATIC_X64" ;;
linuxstatic-arm64) echo "$SHA256_LINUXSTATIC_ARM64" ;;
win-x64) echo "$SHA256_WIN_X64" ;;
win-arm64) echo "$SHA256_WIN_ARM64" ;;
*) echo "" ;;
esac
}
compute_sha256() {
file="$1"
if command_exists sha256sum; then
sha256sum "$file" | awk '{print $1}'
elif command_exists shasum; then
shasum -a 256 "$file" | awk '{print $1}'
else
echo ""
fi
}
# Verifies the downloaded binary against the expected hash baked in by the release pipeline.
# No-op when no expected hash is set (running the script from main).
verify_checksum() {
file="$1"; expected="$2"
if [ -z "$expected" ]; then
return
fi
actual=$(compute_sha256 "$file")
if [ -z "$actual" ]; then
rm -f "$file"
error "Cannot verify checksum: neither sha256sum nor shasum is available. Install one and re-run."
fi
if [ "$actual" != "$expected" ]; then
rm -f "$file"
error "Checksum verification failed. Expected: $expected, Got: $actual"
fi
info "Checksum verified."
}
# Download file
download() {
url="$1"
dest="$2"
if command_exists curl; then
curl -fsSL "$url" -o "$dest" || error "Failed to download from $url"
elif command_exists wget; then
wget -q "$url" -O "$dest" || error "Failed to download from $url"
else
error "Neither curl nor wget found. Please install one of them."
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
remove_npm_installation() {
if ! command_exists npm; then
return
fi
# Check if safe-chain is installed as an npm global package
if npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then
info "Detected npm global installation of @aikidosec/safe-chain"
info "Uninstalling npm version before installing binary version..."
if npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then
info "Successfully uninstalled npm version"
else
warn "Failed to uninstall npm version automatically"
warn "Please run: npm uninstall -g @aikidosec/safe-chain"
fi
fi
}
# Check and uninstall Volta-managed package if present
remove_volta_installation() {
if ! command_exists volta; then
return
fi
# Volta manages global packages in its own directory
# Check if safe-chain is installed via Volta
if volta list safe-chain >/dev/null 2>&1; then
info "Detected Volta installation of @aikidosec/safe-chain"
info "Uninstalling Volta version before installing binary version..."
if volta uninstall @aikidosec/safe-chain >/dev/null 2>&1; then
info "Successfully uninstalled Volta version"
else
warn "Failed to uninstall Volta version automatically"
warn "Please run: volta uninstall @aikidosec/safe-chain"
fi
fi
}
# Check and uninstall nvm-managed package if present across all Node versions
remove_nvm_installation() {
# This script is run in sh shell for greatest compatibility.
# Because nvm is usually setup in bash/zsh/fish startup scripts, we need to source it.
# Otherwise it won't be available in sh.
if [ -s "$HOME/.nvm/nvm.sh" ]; then
# Source nvm to make it available in this script
. "$HOME/.nvm/nvm.sh" >/dev/null 2>&1
elif [ -s "$NVM_DIR/nvm.sh" ]; then
. "$NVM_DIR/nvm.sh" >/dev/null 2>&1
fi
# Check if nvm is now available
if ! command_exists nvm; then
return
fi
nvm_versions=$(nvm list 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "")
if [ -z "$nvm_versions" ]; then
return
fi
# Track if we found any installations
found_installation=false
uninstall_failed=false
current_version=$(nvm current 2>/dev/null || echo "")
# Check each version for safe-chain installation
for version in $nvm_versions; do
# Check if this version has safe-chain installed
# Use nvm exec to run npm list in the context of that Node version
if nvm exec "$version" npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then
if [ "$found_installation" = false ]; then
info "Detected nvm installation(s) of @aikidosec/safe-chain"
info "Uninstalling from all Node versions..."
found_installation=true
fi
info " Removing from Node $version..."
if nvm exec "$version" npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then
info " Successfully uninstalled from Node $version"
else
warn " Failed to uninstall from Node $version"
uninstall_failed=true
fi
fi
done
# Restore original Node version if it was set
if [ -n "$current_version" ] && [ "$current_version" != "none" ] && [ "$current_version" != "system" ]; then
nvm use "$current_version" >/dev/null 2>&1 || true
fi
# If any uninstall failed, error out instead of continuing
if [ "$uninstall_failed" = true ]; then
error "Failed to uninstall @aikidosec/safe-chain from all nvm Node versions. Please uninstall manually and try again."
fi
}
# Parse command-line arguments
parse_arguments() {
while [ $# -gt 0 ]; do
case "$1" in
--ci)
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)
warn "--include-python is deprecated and ignored. Python ecosystem is now included by default."
;;
*)
error "Unknown argument: $1"
;;
esac
shift
done
validate_install_dir "${SAFE_CHAIN_BASE}"
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
}
# Main installation
main() {
# Initialize argument flags
USE_CI_SETUP=false
# Parse command-line arguments
parse_arguments "$@"
warn_deprecated_version_env
ensure_version
# Check if the requested version is already installed
if is_version_installed "$VERSION"; then
info "safe-chain ${VERSION} is already installed"
exit 0
fi
# Build installation message
INSTALL_MSG="Installing safe-chain ${VERSION}"
if [ "$USE_CI_SETUP" = "true" ]; then
INSTALL_MSG="${INSTALL_MSG} in ci"
fi
info "$INSTALL_MSG"
# Check for existing safe-chain installation through nvm, volta, or npm
remove_npm_installation
remove_volta_installation
remove_nvm_installation
# Detect platform
OS=$(detect_os)
ARCH=$(detect_arch)
BINARY_NAME=$(get_binary_name "$OS" "$ARCH")
info "Detected platform: ${OS}-${ARCH}"
# Create installation directory
if [ ! -d "$INSTALL_DIR" ]; then
info "Creating installation directory: $INSTALL_DIR"
mkdir -p "$INSTALL_DIR" || error "Failed to create directory $INSTALL_DIR"
fi
# Download binary
DOWNLOAD_URL="${REPO_URL}/releases/download/${VERSION}/${BINARY_NAME}"
TEMP_FILE="${INSTALL_DIR}/${BINARY_NAME}"
info "Downloading from: $DOWNLOAD_URL"
download "$DOWNLOAD_URL" "$TEMP_FILE"
EXPECTED_SHA256=$(get_expected_sha256 "$OS" "$ARCH")
verify_checksum "$TEMP_FILE" "$EXPECTED_SHA256"
# Rename and make executable
FINAL_FILE=$(get_final_binary_path "$OS")
mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE"
if [ "$OS" != "win" ]; then
chmod +x "$FINAL_FILE" || error "Failed to make binary executable"
fi
info "Binary installed to: $FINAL_FILE"
run_setup_command "$FINAL_FILE"
}
main "$@"

View file

@ -0,0 +1,50 @@
#!/bin/sh
# Uninstalls Aikido Endpoint Protection on macOS
#
# Usage: curl -fsSL <url> | sudo sh
set -e # Exit on error
# Configuration
UNINSTALL_SCRIPT="/Applications/Aikido Endpoint Protection.app/Contents/Resources/scripts/uninstall"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Helper functions
info() {
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
exit 1
}
# Main uninstallation
main() {
# Check if we're running on macOS
if [ "$(uname -s)" != "Darwin" ]; then
error "This script is only supported on macOS."
fi
# Check if we're running as root
if [ "$(id -u)" -ne 0 ]; then
error "Root privileges required. Please re-run with sudo, e.g.: curl -fsSL <url> | sudo sh"
fi
# Check if the uninstall script exists
if [ ! -f "$UNINSTALL_SCRIPT" ]; then
error "Aikido Endpoint Protection does not appear to be installed (uninstall script not found)."
fi
info "Uninstalling Aikido Endpoint Protection..."
"$UNINSTALL_SCRIPT"
info "Aikido Endpoint Protection uninstalled successfully!"
}
main "$@"

View file

@ -0,0 +1,59 @@
# Uninstalls Aikido Endpoint Protection endpoint on Windows
#
# Usage: iex (iwr '<url>' -UseBasicParsing)
# Configuration
$AppName = "Aikido Endpoint Protection"
# Helper functions
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Green
}
function Write-Error-Custom {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
exit 1
}
# Check if running as Administrator
function Test-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# Main uninstallation
function Uninstall-Endpoint {
# Check if we're running as Administrator
if (-not (Test-Administrator)) {
Write-Error-Custom "Administrator privileges required. Please run this script in an elevated terminal (Run as Administrator)."
}
# Find the installed product
Write-Info "Looking for Aikido Endpoint Protection installation..."
$app = Get-WmiObject -Class Win32_Product -Filter "Name='$AppName'"
if (-not $app) {
Write-Error-Custom "Aikido Endpoint Protection does not appear to be installed."
}
$productCode = $app.IdentifyingNumber
Write-Info "Uninstalling Aikido Endpoint Protection..."
$process = Start-Process -FilePath "msiexec" -ArgumentList "/x", $productCode, "/qn", "/norestart" -Wait -PassThru
if ($process.ExitCode -ne 0) {
Write-Error-Custom "Uninstall failed (exit code: $($process.ExitCode))."
}
Write-Info "Aikido Endpoint Protection uninstalled successfully!"
}
# Run uninstallation
try {
Uninstall-Endpoint
}
catch {
Write-Error-Custom "Uninstallation failed: $_"
}

View file

@ -0,0 +1,249 @@
# Uninstalls safe-chain from Windows
#
# Usage with "iex (iwr {url} -UseBasicParsing)" --> See README.md
# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform)
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
# Helper functions
function Write-Info {
param([string]$Message)
Write-Host "[INFO] $Message" -ForegroundColor Green
}
function Write-Warn {
param([string]$Message)
Write-Host "[WARN] $Message" -ForegroundColor Yellow
}
function Write-Error-Custom {
param([string]$Message)
Write-Host "[ERROR] $Message" -ForegroundColor Red
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
function Remove-NpmInstallation {
# Check if npm is available
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
return
}
# Check if safe-chain is installed as an npm global package
npm list -g @aikidosec/safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Detected npm global installation of @aikidosec/safe-chain"
Write-Info "Uninstalling npm version before installing binary version..."
npm uninstall -g @aikidosec/safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Successfully uninstalled npm version"
}
else {
Write-Warn "Failed to uninstall npm version automatically"
Write-Warn "Please run: npm uninstall -g @aikidosec/safe-chain"
}
}
}
# Check and uninstall Volta-managed package if present
function Remove-VoltaInstallation {
# Check if Volta is available
if (-not (Get-Command volta -ErrorAction SilentlyContinue)) {
return
}
# Volta manages global packages in its own directory
# Check if safe-chain is installed via Volta
volta list safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Detected Volta installation of @aikidosec/safe-chain"
Write-Info "Uninstalling Volta version before installing binary version..."
volta uninstall @aikidosec/safe-chain 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Info "Successfully uninstalled Volta version"
}
else {
Write-Warn "Failed to uninstall Volta version automatically"
Write-Warn "Please run: volta uninstall @aikidosec/safe-chain"
}
}
}
# Main uninstallation
function Uninstall-SafeChain {
Write-Info "Uninstalling safe-chain..."
$DotSafeChain = Get-SafeChainInstallDir
$safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain
Invoke-SafeChainTeardown -SafeChainPath $safeChainPath
# Remove npm and Volta installations
Remove-NpmInstallation
Remove-VoltaInstallation
# Remove .safe-chain directory
if (Test-Path $DotSafeChain) {
Write-Info "Removing installation directory: $DotSafeChain"
try {
Remove-Item -Path $DotSafeChain -Recurse -Force
Write-Info "Successfully removed installation directory"
}
catch {
Write-Error-Custom "Failed to remove $DotSafeChain : $_"
}
}
else {
Write-Info "Installation directory $DotSafeChain does not exist. Nothing to remove."
}
Write-Info "safe-chain has been uninstalled successfully!"
}
# Run uninstallation
try {
Uninstall-SafeChain
}
catch {
Write-Error-Custom "Uninstallation failed: $_"
}

View file

@ -0,0 +1,312 @@
#!/bin/sh
# Downloads and installs safe-chain, depending on the operating system and architecture
#
# Usage with "curl -fsSL {url} | sh" --> See README.md
set -e # Exit on error
# Configuration
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Helper functions
info() {
printf "${GREEN}[INFO]${NC} %s\n" "$1"
}
warn() {
printf "${YELLOW}[WARN]${NC} %s\n" "$1"
}
error() {
printf "${RED}[ERROR]${NC} %s\n" "$1" >&2
exit 1
}
# Check if command exists
command_exists() {
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
remove_npm_installation() {
if ! command_exists npm; then
return
fi
# Check if safe-chain is installed as an npm global package
if npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then
info "Detected npm global installation of @aikidosec/safe-chain"
info "Uninstalling npm version before installing binary version..."
if npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then
info "Successfully uninstalled npm version"
else
warn "Failed to uninstall npm version automatically"
warn "Please run: npm uninstall -g @aikidosec/safe-chain"
fi
fi
}
# Check and uninstall Volta-managed package if present
remove_volta_installation() {
if ! command_exists volta; then
return
fi
# Volta manages global packages in its own directory
# Check if safe-chain is installed via Volta
if volta list safe-chain >/dev/null 2>&1; then
info "Detected Volta installation of @aikidosec/safe-chain"
info "Uninstalling Volta version before installing binary version..."
if volta uninstall @aikidosec/safe-chain >/dev/null 2>&1; then
info "Successfully uninstalled Volta version"
else
warn "Failed to uninstall Volta version automatically"
warn "Please run: volta uninstall @aikidosec/safe-chain"
fi
fi
}
# Check and uninstall nvm-managed package if present across all Node versions
remove_nvm_installation() {
# This script is run in sh shell for greatest compatibility.
# Because nvm is usually setup in bash/zsh/fish startup scripts, we need to source it.
# Otherwise it won't be available in sh.
if [ -s "$HOME/.nvm/nvm.sh" ]; then
# Source nvm to make it available in this script
. "$HOME/.nvm/nvm.sh" >/dev/null 2>&1
elif [ -s "$NVM_DIR/nvm.sh" ]; then
. "$NVM_DIR/nvm.sh" >/dev/null 2>&1
fi
# Check if nvm is now available
if ! command_exists nvm; then
return
fi
# Get list of installed Node versions
nvm_versions=$(nvm list 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' || echo "")
if [ -z "$nvm_versions" ]; then
return
fi
# Track if we found any installations
found_installation=false
uninstall_failed=false
current_version=$(nvm current 2>/dev/null || echo "")
# Check each version for safe-chain installation
for version in $nvm_versions; do
# Check if this version has safe-chain installed
# Use nvm exec to run npm list in the context of that Node version
if nvm exec "$version" npm list -g @aikidosec/safe-chain >/dev/null 2>&1; then
if [ "$found_installation" = false ]; then
info "Detected nvm installation(s) of @aikidosec/safe-chain"
info "Uninstalling from all Node versions..."
found_installation=true
fi
info " Removing from Node $version..."
if nvm exec "$version" npm uninstall -g @aikidosec/safe-chain >/dev/null 2>&1; then
info " Successfully uninstalled from Node $version"
else
warn " Failed to uninstall from Node $version"
uninstall_failed=true
fi
fi
done
# Restore original Node version if it was set
if [ -n "$current_version" ] && [ "$current_version" != "none" ] && [ "$current_version" != "system" ]; then
nvm use "$current_version" >/dev/null 2>&1 || true
fi
# Show warning if any uninstall failed (but don't error out during uninstall)
if [ "$uninstall_failed" = true ]; then
warn "Failed to uninstall @aikidosec/safe-chain from some nvm Node versions"
warn "You may need to manually run: nvm exec <version> npm uninstall -g @aikidosec/safe-chain"
fi
}
# Main uninstallation
main() {
DOT_SAFE_CHAIN=$(get_install_dir)
SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true)
run_safe_chain_teardown "$SAFE_CHAIN_COMMAND"
# Check for existing safe-chain installation through nvm, volta, or npm
remove_npm_installation
remove_volta_installation
remove_nvm_installation
# Remove install dir recursively if it exists
if [ -d "$DOT_SAFE_CHAIN" ]; then
info "Removing installation directory $DOT_SAFE_CHAIN"
rm -rf "$DOT_SAFE_CHAIN" || error "Failed to remove $DOT_SAFE_CHAIN"
else
info "Installation directory $DOT_SAFE_CHAIN does not exist. Nothing to remove."
fi
}
main "$@"

3183
npm-shrinkwrap.json generated Normal file

File diff suppressed because it is too large Load diff

4925
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,9 +7,10 @@
"test/e2e"
],
"scripts": {
"test": "npm run test --workspace=packages/safe-chain --workspace=packages/safe-chain-bun",
"test": "npm run test --workspace=packages/safe-chain",
"test:e2e": "npm run test --workspace=test/e2e",
"lint": "npm run lint --workspace=packages/safe-chain"
"lint": "npm run lint --workspace=packages/safe-chain",
"typecheck": "npm run typecheck --workspace=packages/safe-chain"
},
"repository": {
"type": "git",
@ -18,13 +19,8 @@
"author": "Aikido Security",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@eslint/js": "^9.35.0",
"eslint": "^9.35.0",
"eslint-plugin-import": "^2.32.0",
"globals": "^16.1.0",
"typescript-eslint": "^8.32.0"
},
"overrides": {
"brace-expansion@<=2.0.2": "2.0.2"
"oxlint": "^1.22.0",
"esbuild": "^0.27.0",
"@yao-pkg/pkg": "6.10.1"
}
}

View file

@ -1,30 +0,0 @@
{
"name": "@aikidosec/safe-chain-bun",
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"scripts": {
"test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'"
},
"exports": {
".": {
"bun": "./src/index.js",
"default": "./src/index.js"
}
},
"keywords": ["bun", "security", "scanner", "malware", "aikido"],
"author": "Aikido Security",
"license": "AGPL-3.0-or-later",
"description": "Aikido Security Scanner for Bun package manager - detects malware and security threats during package installation",
"repository": {
"type": "git",
"url": "git+https://github.com/AikidoSec/safe-chain.git",
"directory": "packages/safe-chain-bun"
},
"dependencies": {
"@aikidosec/safe-chain": "file:../safe-chain"
},
"peerDependencies": {
"bun": ">=1.2.21"
}
}

View file

@ -1,37 +0,0 @@
import { auditChanges } from "@aikidosec/safe-chain/scanning";
// Bun Security Scanner for Safe-Chain
// This is the entry point for Bun's native security scanner integration
export const scanner = {
version: "1", // Our scanner is using version 1 of the bun security scanner API.
async scan({ packages }) {
const advisories = [];
try {
const changes = packages.map((pkg) => ({
name: pkg.name,
version: pkg.version,
type: "add",
}));
const audit = await auditChanges(changes);
if (!audit.isAllowed) {
for (const change of audit.disallowedChanges) {
advisories.push({
level: "fatal", // Fatal will block the installation process, this is what we want for packages that contain malware.
package: change.name,
url: null,
description: `Package ${change.name}@${change.version} contains known security threats (${change.reason}). Installation blocked by Safe-Chain.`,
});
}
}
} catch (error) {
console.warn(`Safe-Chain security scan failed: ${error.message}`);
}
return advisories;
},
};

View file

@ -1,140 +0,0 @@
import assert from "node:assert/strict";
import { describe, it, mock } from "node:test";
describe("Bun Scanner", async () => {
const mockAuditChanges = mock.fn();
// Mock the scanning module
mock.module("@aikidosec/safe-chain/scanning", {
namedExports: {
auditChanges: mockAuditChanges,
},
});
const { scanner } = await import("./index.js");
it("should export scanner object with version", () => {
assert.strictEqual(scanner.version, "1");
assert.strictEqual(typeof scanner.scan, "function");
});
it("should return empty advisories for clean packages", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [{ name: "express", version: "4.18.2", type: "add" }],
disallowedChanges: [],
isAllowed: true,
}));
const packages = [{ name: "express", version: "4.18.2" }];
const result = await scanner.scan({ packages });
assert.deepEqual(result, []);
assert.strictEqual(mockAuditChanges.mock.callCount(), 1);
assert.deepEqual(mockAuditChanges.mock.calls[0].arguments[0], [
{ name: "express", version: "4.18.2", type: "add" },
]);
});
it("should return fatal advisory for malware packages", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [],
disallowedChanges: [
{
name: "malicious-pkg",
version: "1.0.0",
type: "add",
reason: "MALWARE",
},
],
isAllowed: false,
}));
const packages = [{ name: "malicious-pkg", version: "1.0.0" }];
const result = await scanner.scan({ packages });
assert.strictEqual(result.length, 1);
assert.deepEqual(result[0], {
level: "fatal",
package: "malicious-pkg",
url: null,
description: "Package malicious-pkg@1.0.0 contains known security threats (MALWARE). Installation blocked by Safe-Chain.",
});
});
it("should handle multiple packages with mixed results", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [{ name: "express", version: "4.18.2", type: "add" }],
disallowedChanges: [
{
name: "malicious-pkg",
version: "1.0.0",
type: "add",
reason: "MALWARE",
},
{
name: "another-bad-pkg",
version: "2.1.0",
type: "add",
reason: "MALWARE",
},
],
isAllowed: false,
}));
const packages = [
{ name: "express", version: "4.18.2" },
{ name: "malicious-pkg", version: "1.0.0" },
{ name: "another-bad-pkg", version: "2.1.0" },
];
const result = await scanner.scan({ packages });
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0].package, "malicious-pkg");
assert.strictEqual(result[0].level, "fatal");
assert.strictEqual(result[1].package, "another-bad-pkg");
assert.strictEqual(result[1].level, "fatal");
});
it("should handle empty package list", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [],
disallowedChanges: [],
isAllowed: true,
}));
const result = await scanner.scan({ packages: [] });
assert.deepEqual(result, []);
assert.deepEqual(
mockAuditChanges.mock.calls[mockAuditChanges.mock.callCount() - 1]
.arguments[0],
[]
);
});
it("should convert Bun package format to safe-chain format correctly", async () => {
mockAuditChanges.mock.mockImplementation(() => ({
allowedChanges: [],
disallowedChanges: [],
isAllowed: true,
}));
const bunPackages = [
{ name: "lodash", version: "4.17.21" },
{ name: "@types/node", version: "20.0.0" },
];
await scanner.scan({ packages: bunPackages });
const expectedChanges = [
{ name: "lodash", version: "4.17.21", type: "add" },
{ name: "@types/node", version: "20.0.0", type: "add" },
];
assert.deepEqual(
mockAuditChanges.mock.calls[mockAuditChanges.mock.callCount() - 1]
.arguments[0],
expectedChanges
);
});
});

View file

@ -2,9 +2,13 @@
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 = "bun";
initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -2,9 +2,13 @@
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 = "bunx";
initializePackageManager(packageManagerName);
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -1,21 +1,14 @@
#!/usr/bin/env node
import { execSync } from "child_process";
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 = "npm";
initializePackageManager(packageManagerName, getNpmVersion());
var exitCode = await main(process.argv.slice(2));
initializePackageManager(packageManagerName);
process.exit(exitCode);
function getNpmVersion() {
try {
return execSync("npm --version").toString().trim();
} catch {
// Default to 0.0.0 if npm is not found
// That way we don't use any unsupported features
return "0.0.0";
}
}
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -2,9 +2,13 @@
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 = "npx";
initializePackageManager(packageManagerName, process.versions.node);
var exitCode = await main(process.argv.slice(2));
initializePackageManager(packageManagerName);
process.exit(exitCode);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

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,17 @@
#!/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";
import { PIP_PACKAGE_MANAGER, PIP_COMMAND } from "../src/packagemanager/pip/pipSettings.js";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PIP_COMMAND, args: process.argv.slice(2) });
(async () => {
// Pass through only user-supplied pip args
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -0,0 +1,17 @@
#!/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";
import { PIP_PACKAGE_MANAGER, PIP3_COMMAND } from "../src/packagemanager/pip/pipSettings.js";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PIP3_COMMAND, args: process.argv.slice(2) });
(async () => {
// Pass through only user-supplied pip args
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("pipx");
(async () => {
// Pass through only user-supplied pipx args
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -2,9 +2,13 @@
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 = "pnpm";
initializePackageManager(packageManagerName, process.versions.node);
var exitCode = await main(process.argv.slice(2));
initializePackageManager(packageManagerName);
process.exit(exitCode);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -2,9 +2,13 @@
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 = "pnpx";
initializePackageManager(packageManagerName, process.versions.node);
var exitCode = await main(process.argv.slice(2));
initializePackageManager(packageManagerName);
process.exit(exitCode);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

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("poetry");
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -0,0 +1,19 @@
#!/usr/bin/env node
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { PIP_PACKAGE_MANAGER, PYTHON_COMMAND } from "../src/packagemanager/pip/pipSettings.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
import { main } from "../src/main.js";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
// Strip nodejs and wrapper script from args
let argv = process.argv.slice(2);
initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PYTHON_COMMAND, args: argv });
(async () => {
var exitCode = await main(argv);
process.exit(exitCode);
})();

View file

@ -0,0 +1,19 @@
#!/usr/bin/env node
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { PIP_PACKAGE_MANAGER, PYTHON3_COMMAND } from "../src/packagemanager/pip/pipSettings.js";
import { setEcoSystem, ECOSYSTEM_PY } from "../src/config/settings.js";
import { main } from "../src/main.js";
// Set eco system
setEcoSystem(ECOSYSTEM_PY);
// Strip nodejs and wrapper script from args
let argv = process.argv.slice(2);
initializePackageManager(PIP_PACKAGE_MANAGER, { tool: PYTHON3_COMMAND, args: argv });
(async () => {
var exitCode = await main(argv);
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("uv");
(async () => {
// Pass through only user-supplied uv args
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

@ -2,9 +2,13 @@
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 = "yarn";
initializePackageManager(packageManagerName, process.versions.node);
var exitCode = await main(process.argv.slice(2));
initializePackageManager(packageManagerName);
process.exit(exitCode);
(async () => {
var exitCode = await main(process.argv.slice(2));
process.exit(exitCode);
})();

View file

@ -1,10 +1,42 @@
#!/usr/bin/env node
// Strip PKG_EXECPATH from the environment so any child process safe-chain
// spawns (npm, uv, pip, …) doesn't inherit it. If it leaks into a subsequent
// safe-chain invocation (e.g. via a shim) the yao-pkg bootstrap would treat
// argv[1] as a script path and fail with MODULE_NOT_FOUND.
delete process.env.PKG_EXECPATH;
import chalk from "chalk";
import { ui } from "../src/environment/userInteraction.js";
import { setup } from "../src/shell-integration/setup.js";
import { teardown } from "../src/shell-integration/teardown.js";
import {
teardown,
teardownDirectories,
} from "../src/shell-integration/teardown.js";
import { setupCi } from "../src/shell-integration/setup-ci.js";
import { initializeCliArguments } from "../src/config/cliArguments.js";
import { setEcoSystem } from "../src/config/settings.js";
import { initializePackageManager } from "../src/packagemanager/currentPackageManager.js";
import { main } from "../src/main.js";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js";
import { getInstalledSafeChainDir } from "../src/installLocation.js";
/** @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;
}
if (process.argv.length < 3) {
ui.writeError("No command provided. Please provide a command to execute.");
@ -13,19 +45,50 @@ if (process.argv.length < 3) {
process.exit(1);
}
initializeCliArguments(process.argv);
const command = process.argv[2];
if (command === "help" || command === "--help" || command === "-h") {
const tool = knownAikidoTools.find((tool) => tool.tool === command);
if (tool) {
const args = process.argv.slice(3);
setEcoSystem(tool.ecoSystem);
// Provide tool context to PM (pip uses this; others ignore)
const toolContext = { tool: tool.tool, args };
initializePackageManager(tool.internalPackageManagerName, toolContext);
(async () => {
var exitCode = await main(args);
process.exit(exitCode);
})();
} else if (command === "help" || command === "--help" || command === "-h") {
writeHelp();
process.exit(0);
}
if (command === "setup") {
} else if (command === "setup") {
setup();
} else if (command === "teardown") {
teardown();
teardownDirectories();
} else if (command === "setup-ci") {
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") {
(async () => {
ui.writeInformation(`Current safe-chain version: ${await getVersion()}`);
})();
} else {
ui.writeError(`Unknown command: ${command}.`);
ui.emptyLine();
@ -37,29 +100,54 @@ if (command === "setup") {
function writeHelp() {
ui.writeInformation(
chalk.bold("Usage: ") + chalk.cyan("safe-chain <command>")
chalk.bold("Usage: ") + chalk.cyan("safe-chain <command>"),
);
ui.emptyLine();
ui.writeInformation(
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
"teardown"
)}, ${chalk.cyan("help")}`
"teardown",
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("get-install-dir")}, ${chalk.cyan("help")}, ${chalk.cyan(
"--version",
)}`,
);
ui.emptyLine();
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain setup"
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm and pnpx.`
"safe-chain setup",
)}: This will setup your shell to wrap safe-chain around ${getPackageManagerList()}.`,
);
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain teardown"
)}: This will remove safe-chain aliases from your shell configuration.`
"safe-chain teardown",
)}: This will remove safe-chain aliases from your shell configuration.`,
);
ui.writeInformation(
`- ${chalk.cyan(
"safe-chain setup-ci"
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`
"safe-chain setup-ci",
)}: 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(
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
"-v",
)}): Display the current version of safe-chain.`,
);
ui.emptyLine();
}
async function getVersion() {
const packageJsonPath = path.join(dirname, "..", "package.json");
const data = await fs.promises.readFile(packageJsonPath);
const json = JSON.parse(data.toString("utf8"));
if (json && json.version) {
return json.version;
}
return "0.0.0";
}

View file

@ -4,7 +4,8 @@
"scripts": {
"test": "node --test --experimental-test-module-mocks 'src/**/*.spec.js'",
"test:watch": "node --test --watch --experimental-test-module-mocks 'src/**/*.spec.js'",
"lint": "eslint ."
"lint": "oxlint --deny-warnings",
"typecheck": "tsc --noEmit"
},
"bin": {
"aikido-npm": "bin/aikido-npm.js",
@ -12,8 +13,19 @@
"aikido-yarn": "bin/aikido-yarn.js",
"aikido-pnpm": "bin/aikido-pnpm.js",
"aikido-pnpx": "bin/aikido-pnpx.js",
"aikido-rush": "bin/aikido-rush.js",
"aikido-rushx": "bin/aikido-rushx.js",
"aikido-bun": "bin/aikido-bun.js",
"aikido-bunx": "bin/aikido-bunx.js",
"aikido-uv": "bin/aikido-uv.js",
"aikido-uvx": "bin/aikido-uvx.js",
"aikido-pip": "bin/aikido-pip.js",
"aikido-pip3": "bin/aikido-pip3.js",
"aikido-python": "bin/aikido-python.js",
"aikido-python3": "bin/aikido-python3.js",
"aikido-poetry": "bin/aikido-poetry.js",
"aikido-pipx": "bin/aikido-pipx.js",
"aikido-pdm": "bin/aikido-pdm.js",
"safe-chain": "bin/safe-chain.js"
},
"type": "module",
@ -28,17 +40,27 @@
"keywords": [],
"author": "Aikido Security",
"license": "AGPL-3.0-or-later",
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), and [bunx](https://bun.sh/docs/cli/bunx) 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, or bunx 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": {
"abbrev": "3.0.1",
"certifi": "14.5.15",
"chalk": "5.4.1",
"https-proxy-agent": "7.0.6",
"make-fetch-happen": "14.0.3",
"node-forge": "1.3.1",
"npm-registry-fetch": "18.0.2",
"ora": "8.2.0",
"ini": "6.0.0",
"make-fetch-happen": "15.0.3",
"node-forge": "1.3.2",
"npm-registry-fetch": "19.1.1",
"semver": "7.7.2"
},
"devDependencies": {
"@types/ini": "^4.1.1",
"@types/make-fetch-happen": "^10.0.4",
"@types/node": "^18.19.130",
"@types/node-forge": "^1.3.14",
"@types/npm-registry-fetch": "^8.0.9",
"@types/semver": "^7.7.1",
"esbuild": "^0.27.0",
"typescript": "^5.9.3"
},
"main": "src/main.js",
"bugs": {
"url": "https://github.com/AikidoSec/safe-chain/issues"

View file

@ -1,33 +1,187 @@
import fetch from "make-fetch-happen";
import {
getEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
getMalwareListBaseUrl,
} from "../config/settings.js";
import { ui } from "../environment/userInteraction.js";
const malwareDatabaseUrl =
"https://malware-list.aikido.dev/malware_predictions.json";
const malwareDatabasePaths = {
[ECOSYSTEM_JS]: "malware_predictions.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
* @property {string} package_name
* @property {string} version
* @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}>}
*/
export async function fetchMalwareDatabase() {
const response = await fetch(malwareDatabaseUrl);
if (!response.ok) {
throw new Error(`Error fetching malware database: ${response.statusText}`);
}
return retry(async () => {
const ecosystem = getEcoSystem();
const baseUrl = getMalwareListBaseUrl();
const path = malwareDatabasePaths[
/** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
];
const malwareDatabaseUrl = `${baseUrl}/${path}`;
const response = await fetch(malwareDatabaseUrl);
if (!response.ok) {
throw new Error(
`Error fetching ${ecosystem} malware database: ${response.statusText}`
);
}
try {
let malwareDatabase = await response.json();
return {
malwareDatabase: malwareDatabase,
version: response.headers.get("etag") || undefined,
};
} catch (error) {
throw new Error(`Error parsing malware database: ${error.message}`);
}
try {
let malwareDatabase = await response.json();
return {
malwareDatabase: malwareDatabase,
version: response.headers.get("etag") || undefined,
};
} catch (/** @type {any} */ error) {
throw new Error(`Error parsing malware database: ${error.message}`);
}
}, DEFAULT_FETCH_RETRY_ATTEMPTS);
}
/**
* @returns {Promise<string | undefined>}
*/
export async function fetchMalwareDatabaseVersion() {
const response = await fetch(malwareDatabaseUrl, {
method: "HEAD",
});
if (!response.ok) {
throw new Error(
`Error fetching malware database version: ${response.statusText}`
);
}
return response.headers.get("etag") || undefined;
return retry(async () => {
const ecosystem = getEcoSystem();
const baseUrl = getMalwareListBaseUrl();
const path = malwareDatabasePaths[
/** @type {keyof typeof malwareDatabasePaths} */ (ecosystem)
];
const malwareDatabaseUrl = `${baseUrl}/${path}`;
const response = await fetch(malwareDatabaseUrl, {
method: "HEAD",
});
if (!response.ok) {
throw new Error(
`Error fetching ${ecosystem} malware database version: ${response.statusText}`
);
}
return response.headers.get("etag") || undefined;
}, 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);
}
/**
* Retries an asynchronous function multiple times until it succeeds or exhausts all attempts.
*
* @template T
* @param {() => Promise<T>} func - The asynchronous function to retry
* @param {number} attempts - The number of attempts
* @returns {Promise<T>} The return value of the function if successful
* @throws {Error} The last error encountered if all retry attempts fail
*/
async function retry(func, attempts) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await func();
} catch (error) {
ui.writeVerbose(
"An error occurred while trying to download Aikido data",
error
);
lastError = error;
}
if (i < attempts - 1) {
// When this is not the last try, back-off exponentially:
// 1st attempt - 500ms delay
// 2nd attempt - 1000ms delay
// 3rd attempt - 2000ms delay
// 4th attempt - 4000ms delay
// ...
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 500));
}
}
throw lastError;
}

View file

@ -0,0 +1,231 @@
import { describe, it, mock, beforeEach } from "node:test";
import assert from "node:assert";
describe("aikido API", async () => {
const mockFetch = mock.fn();
let ecosystem = "js";
mock.module("make-fetch-happen", {
defaultExport: mockFetch,
});
mock.module("../environment/userInteraction.js", {
namedExports: {
ui: {
writeVerbose: () => {},
},
},
});
mock.module("../config/settings.js", {
namedExports: {
getEcoSystem: () => ecosystem,
ECOSYSTEM_JS: "js",
ECOSYSTEM_PY: "py",
getMalwareListBaseUrl: () => "https://malware-list.aikido.dev",
},
});
const {
fetchMalwareDatabase,
fetchMalwareDatabaseVersion,
fetchNewPackagesList,
fetchNewPackagesListVersion,
} = await import("./aikido.js");
beforeEach(() => {
mockFetch.mock.resetCalls();
ecosystem = "js";
});
describe("fetchMalwareDatabase", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
const malwareData = [
{ package_name: "malicious-pkg", version: "1.0.0", reason: "test" },
];
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
json: async () => malwareData,
headers: { get: () => '"etag-123"' },
}));
const result = await fetchMalwareDatabase();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.deepStrictEqual(result.malwareDatabase, malwareData);
assert.strictEqual(result.version, '"etag-123"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Network error");
});
await assert.rejects(() => fetchMalwareDatabase(), {
message: "Network error",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
it("should succeed after failing 3 times and succeeding on 4th attempt", async () => {
const malwareData = [
{ package_name: "bad-pkg", version: "2.0.0", reason: "malware" },
];
let callCount = 0;
mockFetch.mock.mockImplementation(() => {
callCount++;
if (callCount < 4) {
throw new Error("Network error");
}
return {
ok: true,
json: async () => malwareData,
headers: { get: () => '"etag-456"' },
};
});
const result = await fetchMalwareDatabase();
assert.strictEqual(mockFetch.mock.calls.length, 4);
assert.deepStrictEqual(result.malwareDatabase, malwareData);
assert.strictEqual(result.version, '"etag-456"');
});
});
describe("fetchMalwareDatabaseVersion", () => {
it("should succeed immediately when fetch succeeds on first try", async () => {
mockFetch.mock.mockImplementationOnce(() => ({
ok: true,
headers: { get: () => '"version-etag"' },
}));
const result = await fetchMalwareDatabaseVersion();
assert.strictEqual(mockFetch.mock.calls.length, 1);
assert.strictEqual(result, '"version-etag"');
});
it("should throw error after exhausting all retries", async () => {
mockFetch.mock.mockImplementation(() => {
throw new Error("Connection refused");
});
await assert.rejects(() => fetchMalwareDatabaseVersion(), {
message: "Connection refused",
});
assert.strictEqual(mockFetch.mock.calls.length, 4);
});
it("should succeed after failing 3 times and succeeding on 4th attempt", async () => {
let callCount = 0;
mockFetch.mock.mockImplementation(() => {
callCount++;
if (callCount < 4) {
throw new Error("Timeout");
}
return {
ok: true,
headers: { get: () => '"final-etag"' },
};
});
const result = await fetchMalwareDatabaseVersion();
assert.strictEqual(mockFetch.mock.calls.length, 4);
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,6 +1,11 @@
import * as semver from "semver";
import * as npmFetch from "npm-registry-fetch";
/**
* @param {string} packageName
* @param {string | null} [versionRange]
* @returns {Promise<string | null>}
*/
export async function resolvePackageVersion(packageName, versionRange) {
if (!versionRange) {
versionRange = "latest";
@ -11,7 +16,10 @@ export async function resolvePackageVersion(packageName, versionRange) {
return versionRange;
}
const packageInfo = await getPackageInfo(packageName);
const packageInfo = (
/** @type {{"dist-tags"?: Record<string, string>, versions?: Record<string, unknown>} | null} */
await getPackageInfo(packageName)
);
if (!packageInfo) {
// It is possible that no version is found (could be a private package, or a package that doesn't exist)
// In this case, we return null to indicate that we couldn't resolve the version
@ -19,12 +27,16 @@ export async function resolvePackageVersion(packageName, versionRange) {
}
const distTags = packageInfo["dist-tags"];
if (distTags && distTags[versionRange]) {
if (distTags && isDistTags(distTags) && distTags[versionRange]) {
// If the version range is a dist-tag, return the version associated with that tag
// e.g., "latest", "next", etc.
return distTags[versionRange];
}
if (!packageInfo.versions) {
return null;
}
// If the version range is not a dist-tag, we need to resolve the highest version matching the range.
// This is useful for ranges like "^1.0.0" or "~2.3.4".
const availableVersions = Object.keys(packageInfo.versions);
@ -37,6 +49,19 @@ export async function resolvePackageVersion(packageName, versionRange) {
return null;
}
/**
*
* @param {unknown} distTags
* @returns {distTags is Record<string, string>}
*/
function isDistTags(distTags) {
return typeof distTags === "object";
}
/**
* @param {string} packageName
* @returns {Promise<Record<string, unknown> | null>}
*/
async function getPackageInfo(packageName) {
try {
return await npmFetch.json(packageName);

View file

@ -0,0 +1,211 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert";
describe("resolvePackageVersion", async () => {
const mockNpmFetchJson = mock.fn();
mock.module("npm-registry-fetch", {
namedExports: {
json: mockNpmFetchJson,
},
});
const { resolvePackageVersion } = await import("./npmApi.js");
it("should return the version if it is already a fixed version", async () => {
const result = await resolvePackageVersion("express", "4.17.1");
assert.strictEqual(result, "4.17.1");
});
it("should use 'latest' as default version range when not provided", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
"dist-tags": {
latest: "4.18.2",
},
versions: {
"4.18.2": {},
},
}));
const result = await resolvePackageVersion("express");
assert.strictEqual(result, "4.18.2");
});
it("should resolve dist-tag versions", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
"dist-tags": {
latest: "4.18.2",
next: "5.0.0-beta.1",
},
versions: {
"4.18.2": {},
"5.0.0-beta.1": {},
},
}));
const result = await resolvePackageVersion("express", "next");
assert.strictEqual(result, "5.0.0-beta.1");
});
it("should resolve version ranges using semver", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
"dist-tags": {
latest: "4.18.2",
},
versions: {
"4.16.0": {},
"4.17.0": {},
"4.17.1": {},
"4.18.0": {},
"4.18.2": {},
},
}));
const result = await resolvePackageVersion("express", "^4.17.0");
assert.strictEqual(result, "4.18.2");
});
it("should resolve tilde ranges correctly", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
"dist-tags": {
latest: "4.18.2",
},
versions: {
"4.17.0": {},
"4.17.1": {},
"4.17.3": {},
"4.18.0": {},
},
}));
const result = await resolvePackageVersion("express", "~4.17.0");
assert.strictEqual(result, "4.17.3");
});
it("should return null if package info cannot be fetched", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => {
throw new Error("Package not found");
});
const result = await resolvePackageVersion("non-existent-package", "latest");
assert.strictEqual(result, null);
});
it("should return null if no versions match the range", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
"dist-tags": {
latest: "1.0.0",
},
versions: {
"1.0.0": {},
"1.1.0": {},
},
}));
const result = await resolvePackageVersion("express", "^5.0.0");
assert.strictEqual(result, null);
});
it("should return null if dist-tag does not exist", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
"dist-tags": {
latest: "4.18.2",
},
versions: {
"4.18.2": {},
},
}));
const result = await resolvePackageVersion("express", "nonexistent-tag");
assert.strictEqual(result, null);
});
it("should return null if package info has no versions property (retracted package)", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
_id: "zenn",
name: "zenn",
time: {
modified: "2021-04-20T16:20:56.084Z",
created: "2017-07-10T19:48:07.891Z",
unpublished: {
time: "2021-04-20T16:20:56.084Z",
versions: [
"0.9.0",
"0.9.1",
"0.9.2",
"0.9.3",
"0.9.4",
"0.9.5",
"0.9.6",
"0.9.8",
"0.9.9",
"0.9.10",
"0.9.11",
"0.9.12",
"0.9.13",
"0.9.14",
],
},
},
}));
const result = await resolvePackageVersion("zenn", "^0.9.0");
assert.strictEqual(result, null);
});
it("should return dist-tag version even if versions property is missing", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
"dist-tags": {
latest: "4.18.2",
},
}));
const result = await resolvePackageVersion("express", "latest");
assert.strictEqual(result, "4.18.2");
});
it("should handle scoped packages", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
"dist-tags": {
latest: "1.2.3",
},
versions: {
"1.2.3": {},
},
}));
const result = await resolvePackageVersion("@scope/package", "latest");
assert.strictEqual(result, "1.2.3");
});
it("should handle complex version ranges", async () => {
mockNpmFetchJson.mock.mockImplementationOnce(() => ({
"dist-tags": {
latest: "2.5.0",
},
versions: {
"1.0.0": {},
"2.0.0": {},
"2.3.0": {},
"2.4.0": {},
"2.5.0": {},
"3.0.0": {},
},
}));
const result = await resolvePackageVersion("express", ">=2.0.0 <3.0.0");
assert.strictEqual(result, "2.5.0");
});
});

View file

@ -1,12 +1,27 @@
import { ui } from "../environment/userInteraction.js";
/**
* @type {{loggingLevel: string | undefined, skipMinimumPackageAge: boolean | undefined, minimumPackageAgeHours: string | undefined, malwareListBaseUrl: string | undefined}}
*/
const state = {
malwareAction: undefined,
loggingLevel: undefined,
skipMinimumPackageAge: undefined,
minimumPackageAgeHours: undefined,
malwareListBaseUrl: undefined,
};
const SAFE_CHAIN_ARG_PREFIX = "--safe-chain-";
/**
* @param {string[]} args
* @returns {string[]}
*/
export function initializeCliArguments(args) {
// Reset state on each call
state.malwareAction = undefined;
state.loggingLevel = undefined;
state.skipMinimumPackageAge = undefined;
state.minimumPackageAgeHours = undefined;
state.malwareListBaseUrl = undefined;
const safeChainArgs = [];
const remainingArgs = [];
@ -19,21 +34,19 @@ export function initializeCliArguments(args) {
}
}
setMalwareAction(safeChainArgs);
setLoggingLevel(safeChainArgs);
setSkipMinimumPackageAge(safeChainArgs);
setMinimumPackageAgeHours(safeChainArgs);
setMalwareListBaseUrl(safeChainArgs);
checkDeprecatedPythonFlag(args);
return remainingArgs;
}
function setMalwareAction(args) {
const safeChainMalwareActionArg = SAFE_CHAIN_ARG_PREFIX + "malware-action=";
const action = getLastArgEqualsValue(args, safeChainMalwareActionArg);
if (!action) {
return;
}
state.malwareAction = action.toLowerCase();
}
/**
* @param {string[]} args
* @param {string} prefix
* @returns {string | undefined}
*/
function getLastArgEqualsValue(args, prefix) {
for (var i = args.length - 1; i >= 0; i--) {
const arg = args[i];
@ -45,6 +58,104 @@ function getLastArgEqualsValue(args, prefix) {
return undefined;
}
export function getMalwareAction() {
return state.malwareAction;
/**
* @param {string[]} args
* @returns {void}
*/
function setLoggingLevel(args) {
const safeChainLoggingArg = SAFE_CHAIN_ARG_PREFIX + "logging=";
const level = getLastArgEqualsValue(args, safeChainLoggingArg);
if (!level) {
return;
}
state.loggingLevel = level.toLowerCase();
}
export function getLoggingLevel() {
return state.loggingLevel;
}
/**
* @param {string[]} args
* @returns {void}
*/
function setSkipMinimumPackageAge(args) {
const flagName = SAFE_CHAIN_ARG_PREFIX + "skip-minimum-package-age";
if (hasFlagArg(args, flagName)) {
state.skipMinimumPackageAge = true;
}
}
export function getSkipMinimumPackageAge() {
return state.skipMinimumPackageAge;
}
/**
* @param {string[]} args
* @returns {void}
*/
function setMinimumPackageAgeHours(args) {
const argName = SAFE_CHAIN_ARG_PREFIX + "minimum-package-age-hours=";
const value = getLastArgEqualsValue(args, argName);
if (value) {
state.minimumPackageAgeHours = value;
}
}
/**
* @returns {string | undefined}
*/
export function getMinimumPackageAgeHours() {
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} flagName
* @returns {boolean}
*/
function hasFlagArg(args, flagName) {
for (const arg of args) {
if (arg.toLowerCase() === flagName.toLowerCase()) {
return true;
}
}
return false;
}
/**
* Emits a deprecation warning for legacy --include-python flag
*
* @param {string[]} args
* @returns {void}
*/
export function checkDeprecatedPythonFlag(args) {
if (hasFlagArg(args, "--include-python")) {
ui.writeWarning(
"--include-python is deprecated and ignored. Python tooling is included by default."
);
}
}

View file

@ -1,6 +1,12 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { initializeCliArguments, getMalwareAction } from "./cliArguments.js";
import {
initializeCliArguments,
getLoggingLevel,
getSkipMinimumPackageAge,
getMinimumPackageAgeHours,
} from "./cliArguments.js";
import { ui } from "../environment/userInteraction.js";
describe("initializeCliArguments", () => {
it("should return all args when no safe-chain args are present", () => {
@ -57,52 +63,249 @@ describe("initializeCliArguments", () => {
assert.deepEqual(result, ["install", "my--safe-chain-package", "--save"]);
});
it("should not set malwareAction when no safe-chain arguments are passed", () => {
it("should not set loggingLevel when no logging argument is passed", () => {
const args = ["install", "express", "--save"];
initializeCliArguments(args);
assert.strictEqual(getLoggingLevel(), undefined);
});
it("should parse logging=silent and set state", () => {
const args = ["--safe-chain-logging=silent", "install", "package"];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "package"]);
assert.strictEqual(getLoggingLevel(), "silent");
});
it("should parse logging=normal and set state", () => {
const args = ["--safe-chain-logging=normal", "install", "package"];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "package"]);
assert.strictEqual(getLoggingLevel(), "normal");
});
it("should handle multiple logging args, using the last one", () => {
const args = [
"--safe-chain-logging=normal",
"--safe-chain-logging=silent",
"install",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install"]);
assert.strictEqual(getLoggingLevel(), "silent");
});
it("should handle logging level case-insensitively", () => {
const args = ["--safe-chain-logging=SILENT", "install"];
initializeCliArguments(args);
assert.strictEqual(getLoggingLevel(), "silent");
});
it("should capture invalid logging level as-is (lowercased)", () => {
const args = ["--safe-chain-logging=invalid", "install"];
initializeCliArguments(args);
assert.strictEqual(getLoggingLevel(), "invalid");
});
it("should handle logging with other safe-chain args", () => {
const args = [
"--safe-chain-debug",
"--safe-chain-logging=silent",
"--safe-chain-malware-action=block",
"install",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install"]);
assert.strictEqual(getLoggingLevel(), "silent");
});
it("should not set skipMinimumPackageAge when flag is absent", () => {
const args = ["install", "express", "--save"];
initializeCliArguments(args);
assert.strictEqual(getSkipMinimumPackageAge(), undefined);
});
it("should set skipMinimumPackageAge to true when flag is present", () => {
const args = ["--safe-chain-skip-minimum-package-age", "install", "lodash"];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getSkipMinimumPackageAge(), true);
});
it("should handle skip-minimum-package-age flag case-insensitively", () => {
const args = ["--SAFE-CHAIN-SKIP-MINIMUM-PACKAGE-AGE", "install"];
initializeCliArguments(args);
assert.strictEqual(getSkipMinimumPackageAge(), true);
});
it("should filter out skip-minimum-package-age flag from returned args", () => {
const args = [
"install",
"--safe-chain-skip-minimum-package-age",
"express",
"--save",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "express", "--save"]);
assert.strictEqual(getMalwareAction(), undefined);
});
it("should parse malware-action=block and set state", () => {
const args = ["--safe-chain-malware-action=block", "install", "package"];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "package"]);
assert.strictEqual(getMalwareAction(), "block");
});
it("should parse malware-action=prompt and set state", () => {
const args = ["--safe-chain-malware-action=prompt", "install", "package"];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "package"]);
assert.strictEqual(getMalwareAction(), "prompt");
});
it("should handle multiple malware-action args, using the last valid one", () => {
it("should handle skip-minimum-package-age with other safe-chain arguments", () => {
const args = [
"--safe-chain-malware-action=block",
"--safe-chain-malware-action=prompt",
"--safe-chain-logging=verbose",
"--safe-chain-skip-minimum-package-age",
"install",
"lodash",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install"]);
assert.strictEqual(getMalwareAction(), "prompt");
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getLoggingLevel(), "verbose");
assert.strictEqual(getSkipMinimumPackageAge(), true);
});
it("should handle malware-action with other safe-chain args", () => {
it("should handle skip-minimum-package-age flag in different positions", () => {
const args = ["install", "lodash", "--safe-chain-skip-minimum-package-age"];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getSkipMinimumPackageAge(), true);
});
it("should return undefined when no minimum-package-age-hours argument is passed", () => {
const args = ["install", "express", "--save"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), undefined);
});
it("should parse minimum-package-age-hours value and set state", () => {
const args = [
"--safe-chain-debug",
"--safe-chain-malware-action=block",
"--safe-chain-verbose",
"--safe-chain-minimum-package-age-hours=48",
"install",
"lodash",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install"]);
assert.strictEqual(getMalwareAction(), "block");
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getMinimumPackageAgeHours(), "48");
});
it("should handle minimum-package-age-hours with zero value", () => {
const args = ["--safe-chain-minimum-package-age-hours=0", "install"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "0");
});
it("should handle minimum-package-age-hours with decimal values", () => {
const args = ["--safe-chain-minimum-package-age-hours=1.5", "install"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "1.5");
});
it("should handle minimum-package-age-hours case-insensitively", () => {
const args = ["--SAFE-CHAIN-MINIMUM-PACKAGE-AGE-HOURS=72", "install"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "72");
});
it("should use the last minimum-package-age-hours argument when multiple are provided", () => {
const args = [
"--safe-chain-minimum-package-age-hours=12",
"--safe-chain-minimum-package-age-hours=36",
"install",
];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "36");
});
it("should filter out minimum-package-age-hours argument from returned args", () => {
const args = [
"install",
"--safe-chain-minimum-package-age-hours=48",
"express",
"--save",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "express", "--save"]);
});
it("should handle minimum-package-age-hours with other safe-chain arguments", () => {
const args = [
"--safe-chain-logging=verbose",
"--safe-chain-minimum-package-age-hours=96",
"install",
"lodash",
];
const result = initializeCliArguments(args);
assert.deepEqual(result, ["install", "lodash"]);
assert.strictEqual(getLoggingLevel(), "verbose");
assert.strictEqual(getMinimumPackageAgeHours(), "96");
});
it("should handle non-numeric values without validation (validation in settings.js)", () => {
const args = ["--safe-chain-minimum-package-age-hours=invalid", "install"];
initializeCliArguments(args);
// cliArguments.js just captures the value; validation is in settings.js
assert.strictEqual(getMinimumPackageAgeHours(), "invalid");
});
it("should handle negative values as strings (validation in settings.js)", () => {
const args = ["--safe-chain-minimum-package-age-hours=-24", "install"];
initializeCliArguments(args);
assert.strictEqual(getMinimumPackageAgeHours(), "-24");
});
it("should warn on deprecated --include-python for setup", () => {
const warnings = [];
const originalWriteWarning = ui.writeWarning;
ui.writeWarning = (msg, ..._rest) => {
warnings.push(String(msg));
};
try {
const argv = ["node", "safe-chain", "setup", "--include-python"];
initializeCliArguments(argv);
assert.ok(
warnings.some((m) => m.includes("--include-python is deprecated")),
"Expected a deprecation warning for --include-python in setup"
);
} finally {
ui.writeWarning = originalWriteWarning;
}
});
it("should warn on deprecated --include-python for setup-ci", () => {
const warnings = [];
const originalWriteWarning = ui.writeWarning;
ui.writeWarning = (msg, ..._rest) => {
warnings.push(String(msg));
};
try {
const argv = ["node", "safe-chain", "setup-ci", "--include-python"];
initializeCliArguments(argv);
assert.ok(
warnings.some((m) => m.includes("--include-python is deprecated")),
"Expected a deprecation warning for --include-python in setup-ci"
);
} finally {
ui.writeWarning = originalWriteWarning;
}
});
});

View file

@ -2,14 +2,176 @@ import fs from "fs";
import path from "path";
import os from "os";
import { ui } from "../environment/userInteraction.js";
import { getEcoSystem } from "./settings.js";
import { getSafeChainBaseDir } from "./safeChainDir.js";
/**
* @typedef {Object} SafeChainConfig
*
* We cannot trust the input and should add the necessary validations
* @property {unknown | Number} scanTimeout
* @property {unknown | Number} minimumPackageAgeHours
* @property {unknown | string} malwareListBaseUrl
* @property {unknown | SafeChainRegistryConfiguration} npm
* @property {unknown | SafeChainRegistryConfiguration} pip
*
* @typedef {Object} SafeChainRegistryConfiguration
* We cannot trust the input and should add the necessary validations.
* @property {unknown | string[]} customRegistries
* @property {unknown | string[]} minimumPackageAgeExclusions
*/
/**
* @returns {number}
*/
export function getScanTimeout() {
const config = readConfigFile();
return (
parseInt(process.env.AIKIDO_SCAN_TIMEOUT_MS) || config.scanTimeout || 10000 // Default to 10 seconds
);
if (process.env.AIKIDO_SCAN_TIMEOUT_MS) {
const scanTimeout = validateTimeout(process.env.AIKIDO_SCAN_TIMEOUT_MS);
if (scanTimeout != null) {
return scanTimeout;
}
}
if (config.scanTimeout) {
const scanTimeout = validateTimeout(config.scanTimeout);
if (scanTimeout != null) {
return scanTimeout;
}
}
return 10000; // Default to 10 seconds
}
/**
*
* @param {any} value
* @returns {number?}
*/
function validateTimeout(value) {
const timeout = Number(value);
if (!Number.isNaN(timeout) && timeout > 0) {
return timeout;
}
return null;
}
/**
* @param {any} value
* @returns {number | undefined}
*/
function validateMinimumPackageAgeHours(value) {
const hours = Number(value);
if (!Number.isNaN(hours)) {
return hours;
}
return undefined;
}
/**
* Gets the minimum package age in hours from config file only
* @returns {number | undefined}
*/
export function getMinimumPackageAgeHours() {
const config = readConfigFile();
if (config.minimumPackageAgeHours !== undefined) {
const validated = validateMinimumPackageAgeHours(
config.minimumPackageAgeHours
);
if (validated !== undefined) {
return validated;
}
}
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)
* @returns {string[]}
*/
export function getNpmCustomRegistries() {
const config = readConfigFile();
if (!config || !config.npm) {
return [];
}
// TypeScript needs help understanding that config.npm exists and has customRegistries
const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm);
const customRegistries = npmConfig.customRegistries;
if (!Array.isArray(customRegistries)) {
return [];
}
return customRegistries.filter((item) => typeof item === "string");
}
/**
* Gets the custom npm registries from the config file (format parsing only, no validation)
* @returns {string[]}
*/
export function getPipCustomRegistries() {
const config = readConfigFile();
if (!config || !config.pip) {
return [];
}
// TypeScript needs help understanding that config.pip exists and has customRegistries
const pipConfig = /** @type {SafeChainRegistryConfiguration} */ (config.pip);
const customRegistries = pipConfig.customRegistries;
if (!Array.isArray(customRegistries)) {
return [];
}
return customRegistries.filter((item) => typeof item === "string");
}
/**
* Gets the minimum package age exclusions from the config file for the current ecosystem
* @returns {string[]}
*/
export function getMinimumPackageAgeExclusions() {
const config = readConfigFile();
const ecosystem = getEcoSystem();
const registryConfig = ecosystem === "py" ? config.pip : config.npm;
if (!config || !registryConfig) {
return [];
}
const typedRegistryConfig =
/** @type {SafeChainRegistryConfiguration} */ (registryConfig);
const exclusions = typedRegistryConfig.minimumPackageAgeExclusions;
if (!Array.isArray(exclusions)) {
return [];
}
return exclusions.filter((item) => typeof item === "string");
}
/**
* @param {import("../api/aikido.js").MalwarePackage[]} data
* @param {string | number} version
*
* @returns {void}
*/
export function writeDatabaseToLocalCache(data, version) {
try {
const databasePath = getDatabasePath();
@ -24,6 +186,9 @@ export function writeDatabaseToLocalCache(data, version) {
}
}
/**
* @returns {{malwareDatabase: import("../api/aikido.js").MalwarePackage[] | null, version: string | null}}
*/
export function readDatabaseFromLocalCache() {
try {
const databasePath = getDatabasePath();
@ -55,31 +220,102 @@ export function readDatabaseFromLocalCache() {
}
}
/**
* @returns {SafeChainConfig}
*/
function readConfigFile() {
/** @type {SafeChainConfig} */
const emptyConfig = {
scanTimeout: undefined,
minimumPackageAgeHours: undefined,
malwareListBaseUrl: undefined,
npm: {
customRegistries: undefined,
},
pip: {
customRegistries: undefined,
},
};
const configFilePath = getConfigFilePath();
if (!fs.existsSync(configFilePath)) {
return {};
return emptyConfig;
}
const data = fs.readFileSync(configFilePath, "utf8");
return JSON.parse(data);
try {
const data = fs.readFileSync(configFilePath, "utf8");
return JSON.parse(data);
} catch {
return emptyConfig;
}
}
/**
* @returns {string}
*/
function getDatabasePath() {
const aikidoDir = getAikidoDirectory();
return path.join(aikidoDir, "malwareDatabase.json");
const ecosystem = getEcoSystem();
return path.join(aikidoDir, `malwareDatabase_${ecosystem}.json`);
}
function getDatabaseVersionPath() {
const aikidoDir = getAikidoDirectory();
return path.join(aikidoDir, "version.txt");
const ecosystem = getEcoSystem();
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}
*/
function getConfigFilePath() {
return path.join(getAikidoDirectory(), "config.json");
const primaryPath = path.join(getSafeChainDirectory(), "config.json");
if (fs.existsSync(primaryPath)) {
return primaryPath;
}
const legacyPath = path.join(getAikidoDirectory(), "config.json");
if (fs.existsSync(legacyPath)) {
return legacyPath;
}
return primaryPath;
}
/**
* @returns {string}
*/
export function getSafeChainDirectory() {
const safeChainDir = getSafeChainBaseDir();
if (!fs.existsSync(safeChainDir)) {
fs.mkdirSync(safeChainDir, { recursive: true });
}
return safeChainDir;
}
/**
* @returns {string}
*/
function getAikidoDirectory() {
const homeDir = os.homedir();
const aikidoDir = path.join(homeDir, ".aikido");

View file

@ -0,0 +1,380 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import os from "os";
import path from "path";
const safeChainConfigPath = path.join(os.homedir(), ".safe-chain", "config.json");
const aikidoConfigPath = path.join(os.homedir(), ".aikido", "config.json");
/** @type {Map<string, string>} */
let mockFiles = new Map();
mock.module("fs", {
namedExports: {
existsSync: (filePath) => mockFiles.has(filePath),
readFileSync: (filePath) => {
if (!mockFiles.has(filePath)) {
throw new Error(`ENOENT: no such file: ${filePath}`);
}
return mockFiles.get(filePath);
},
writeFileSync: (filePath, content) => mockFiles.set(filePath, content),
mkdirSync: () => {},
},
});
/**
* Helper to set config content at the primary (~/.safe-chain/) location.
* @param {string} content
*/
function setConfigContent(content) {
mockFiles.set(safeChainConfigPath, content);
}
describe("getScanTimeout", async () => {
let originalEnv;
const { getScanTimeout } = await import("./configFile.js");
beforeEach(async () => {
// Save original environment
originalEnv = process.env.AIKIDO_SCAN_TIMEOUT_MS;
});
afterEach(() => {
// Restore original environment
if (originalEnv !== undefined) {
process.env.AIKIDO_SCAN_TIMEOUT_MS = originalEnv;
} else {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
}
mockFiles.clear();
});
it("should return default timeout of 10000ms when no config or env var is set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
const timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
it("should return timeout from config file when set", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const timeout = getScanTimeout();
assert.strictEqual(timeout, 5000);
});
it("should prioritize environment variable over config file", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "20000";
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const timeout = getScanTimeout();
assert.strictEqual(timeout, 20000);
});
it("should handle invalid environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "invalid";
setConfigContent(JSON.stringify({ scanTimeout: 7000 }));
const timeout = getScanTimeout();
assert.strictEqual(timeout, 7000);
});
it("should ignore zero and negative values and fall back to default", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "0";
let timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
process.env.AIKIDO_SCAN_TIMEOUT_MS = "-5000";
timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
it("should ignore textual non-numeric values in environment variable and fall back to config", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "fast";
setConfigContent(JSON.stringify({ scanTimeout: 8000 }));
const timeout = getScanTimeout();
assert.strictEqual(timeout, 8000);
});
it("should ignore textual non-numeric values in config file and fall back to default", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
setConfigContent(JSON.stringify({ scanTimeout: "slow" }));
const timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
it("should ignore textual non-numeric values in both env and config, fall back to default", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "quick";
setConfigContent(JSON.stringify({ scanTimeout: "medium" }));
const timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
it("should ignore mixed alphanumeric strings in environment variable", () => {
process.env.AIKIDO_SCAN_TIMEOUT_MS = "5000ms";
setConfigContent(JSON.stringify({ scanTimeout: 6000 }));
const timeout = getScanTimeout();
assert.strictEqual(timeout, 6000);
});
it("should ignore mixed alphanumeric strings in config file", () => {
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
setConfigContent(JSON.stringify({ scanTimeout: "3000ms" }));
const timeout = getScanTimeout();
assert.strictEqual(timeout, 10000);
});
});
describe("getMinimumPackageAgeHours", async () => {
const { getMinimumPackageAgeHours } = await import("./configFile.js");
afterEach(() => {
mockFiles.clear();
});
it("should return null when config file doesn't exist", () => {
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return null when config file exists but minimumPackageAgeHours is not set", () => {
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return value from config file when set to valid number", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 48 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 48);
});
it("should handle string numbers in config file", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "72" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 72);
});
it("should handle decimal values", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 1.5 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 1.5);
});
it("should return null for non-numeric strings", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "invalid" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return undefined for values with units suffix", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "48h" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should handle malformed JSON and return null", () => {
setConfigContent("{ invalid json");
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, undefined);
});
it("should return 0 when minimumPackageAgeHours is set to 0", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: 0 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 0);
});
it("should return 0 when minimumPackageAgeHours is set to string '0'", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "0" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, 0);
});
it("should handle negative numeric values", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: -24 }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, -24);
});
it("should handle negative string values", () => {
setConfigContent(JSON.stringify({ minimumPackageAgeHours: "-48" }));
const hours = getMinimumPackageAgeHours();
assert.strictEqual(hours, -48);
});
});
const { getNpmCustomRegistries, getPipCustomRegistries } = await import(
"./configFile.js"
);
for (const { packageManager, getCustomRegistries } of [
{
packageManager: "npm",
getCustomRegistries: getNpmCustomRegistries,
},
{
packageManager: "pip",
getCustomRegistries: getPipCustomRegistries,
},
])
{
describe(getCustomRegistries.name, async () => {
afterEach(() => {
mockFiles.clear();
});
it("should return empty array when config file doesn't exist", () => {
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it(`should return empty array when ${packageManager} config is not set`, () => {
setConfigContent(JSON.stringify({ scanTimeout: 5000 }));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return empty array when customRegistries is not an array", () => {
setConfigContent(JSON.stringify({
[packageManager]: { customRegistries: "not-an-array" },
}));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return array of custom registries when set", () => {
setConfigContent(JSON.stringify({
[packageManager]: {
customRegistries: [`${packageManager}.company.com`, "registry.internal.net"],
},
}));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should filter out non-string values", () => {
setConfigContent(JSON.stringify({
[packageManager]: {
customRegistries: [
`${packageManager}.company.com`,
123,
null,
"registry.internal.net",
undefined,
{},
],
},
}));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should return empty array for empty customRegistries array", () => {
setConfigContent(JSON.stringify({
[packageManager]: { customRegistries: [] },
}));
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should handle malformed JSON and return empty array", () => {
setConfigContent("{ invalid json");
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
});
}
describe("config file location fallback", async () => {
const { getScanTimeout } = await import("./configFile.js");
afterEach(() => {
mockFiles.clear();
delete process.env.AIKIDO_SCAN_TIMEOUT_MS;
});
it("should read config from ~/.safe-chain/config.json when it exists", () => {
mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 }));
assert.strictEqual(getScanTimeout(), 3000);
});
it("should fall back to ~/.aikido/config.json when primary does not exist", () => {
mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 }));
assert.strictEqual(getScanTimeout(), 4000);
});
it("should prefer ~/.safe-chain/config.json when both exist", () => {
mockFiles.set(safeChainConfigPath, JSON.stringify({ scanTimeout: 3000 }));
mockFiles.set(aikidoConfigPath, JSON.stringify({ scanTimeout: 4000 }));
assert.strictEqual(getScanTimeout(), 3000);
});
it("should return default when neither config file exists", () => {
assert.strictEqual(getScanTimeout(), 10000);
});
});

View file

@ -0,0 +1,57 @@
/**
* Gets the minimum package age in hours from environment variable
* @returns {string | undefined}
*/
export function getMinimumPackageAgeHours() {
return process.env.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_HOURS;
}
/**
* Gets the custom npm registries from environment variable
* Expected format: comma-separated list of registry domains
* Example: "npm.company.com,registry.internal.net"
* @returns {string | undefined}
*/
export function getNpmCustomRegistries() {
return process.env.SAFE_CHAIN_NPM_CUSTOM_REGISTRIES;
}
/**
* Gets the custom pip registries from environment variable
* Expected format: comma-separated list of registry domains
* Example: "pip.company.com,registry.internal.net"
* @returns {string | undefined}
*/
export function getPipCustomRegistries() {
return process.env.SAFE_CHAIN_PIP_CUSTOM_REGISTRIES;
}
/**
* Gets the logging level from environment variable
* Valid values: "silent", "normal", "verbose"
* @returns {string | undefined}
*/
export function getLoggingLevel() {
return process.env.SAFE_CHAIN_LOGGING;
}
/**
* Gets the minimum package age exclusions from environment variable
* Expected format: comma-separated list of package names
* Example: "react,@aikidosec/safe-chain,lodash"
* @returns {string | undefined}
*/
export function getMinimumPackageAgeExclusions() {
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,14 +1,247 @@
import * as cliArguments from "./cliArguments.js";
import * as configFile from "./configFile.js";
import * as environmentVariables from "./environmentVariables.js";
import { ui } from "../environment/userInteraction.js";
export function getMalwareAction() {
const action = cliArguments.getMalwareAction();
export const LOGGING_SILENT = "silent";
export const LOGGING_NORMAL = "normal";
export const LOGGING_VERBOSE = "verbose";
if (action === MALWARE_ACTION_PROMPT) {
return MALWARE_ACTION_PROMPT;
export function getLoggingLevel() {
// Priority 1: CLI argument
const cliLevel = cliArguments.getLoggingLevel();
if (cliLevel === LOGGING_SILENT || cliLevel === LOGGING_VERBOSE) {
return cliLevel;
}
if (cliLevel) {
// CLI arg was set but invalid, default to normal for backwards compatibility.
return LOGGING_NORMAL;
}
return MALWARE_ACTION_BLOCK;
// Priority 2: Environment variable
const envLevel = environmentVariables.getLoggingLevel()?.toLowerCase();
if (envLevel === LOGGING_SILENT || envLevel === LOGGING_VERBOSE) {
return envLevel;
}
return LOGGING_NORMAL;
}
export const MALWARE_ACTION_BLOCK = "block";
export const MALWARE_ACTION_PROMPT = "prompt";
export const ECOSYSTEM_JS = "js";
export const ECOSYSTEM_PY = "py";
// Default to JavaScript ecosystem
const ecosystemSettings = {
ecoSystem: ECOSYSTEM_JS,
};
/** @returns {string} - The current ecosystem setting (ECOSYSTEM_JS or ECOSYSTEM_PY) */
export function getEcoSystem() {
return ecosystemSettings.ecoSystem;
}
/**
* @param {string} setting - The ecosystem to set (ECOSYSTEM_JS or ECOSYSTEM_PY)
*/
export function setEcoSystem(setting) {
ecosystemSettings.ecoSystem = setting;
}
const defaultMinimumPackageAge = 48;
/** @returns {number} */
export function getMinimumPackageAgeHours() {
// Priority 1: CLI argument
const cliValue = validateMinimumPackageAgeHours(
cliArguments.getMinimumPackageAgeHours()
);
if (cliValue !== undefined) {
return cliValue;
}
// Priority 2: Environment variable
const envValue = validateMinimumPackageAgeHours(
environmentVariables.getMinimumPackageAgeHours()
);
if (envValue !== undefined) {
return envValue;
}
// Priority 3: Config file
const configValue = configFile.getMinimumPackageAgeHours();
if (configValue !== undefined) {
return configValue;
}
return defaultMinimumPackageAge;
}
/**
* @param {string | undefined} value
* @returns {number | undefined}
*/
function validateMinimumPackageAgeHours(value) {
if (!value) {
return undefined;
}
const numericValue = Number(value);
if (Number.isNaN(numericValue)) {
return undefined;
}
if (numericValue >= 0) {
return numericValue;
}
return undefined;
}
const defaultSkipMinimumPackageAge = false;
export function skipMinimumPackageAge() {
const cliValue = cliArguments.getSkipMinimumPackageAge();
if (cliValue === true) {
return true;
}
return defaultSkipMinimumPackageAge;
}
/**
* Normalizes a registry URL by removing protocol if present
* @param {string} registry
* @returns {string}
*/
function normalizeRegistry(registry) {
// Remove protocol (http://, https://) if present
return registry.replace(/^https?:\/\//, "");
}
/**
* Parses comma-separated registries from environment variable
* @param {string | undefined} envValue
* @returns {string[]}
*/
function parseRegistriesFromEnv(envValue) {
if (!envValue || typeof envValue !== "string") {
return [];
}
// Split by comma and trim whitespace
return envValue
.split(",")
.map((registry) => registry.trim())
.filter((registry) => registry.length > 0);
}
/**
* Gets the custom npm registries from both environment variable and config file (merged)
* @returns {string[]}
*/
export function getNpmCustomRegistries() {
const envRegistries = parseRegistriesFromEnv(
environmentVariables.getNpmCustomRegistries()
);
const configRegistries = configFile.getNpmCustomRegistries();
// Merge both sources and remove duplicates
const allRegistries = [...envRegistries, ...configRegistries];
const uniqueRegistries = [...new Set(allRegistries)];
// Normalize each registry (remove protocol if any)
return uniqueRegistries.map(normalizeRegistry);
}
/**
* Gets the custom npm registries from both environment variable and config file (merged)
* @returns {string[]}
*/
export function getPipCustomRegistries() {
const envRegistries = parseRegistriesFromEnv(
environmentVariables.getPipCustomRegistries()
);
const configRegistries = configFile.getPipCustomRegistries();
// Merge both sources and remove duplicates
const allRegistries = [...envRegistries, ...configRegistries];
const uniqueRegistries = [...new Set(allRegistries)];
// Normalize each registry (remove protocol if any)
return uniqueRegistries.map(normalizeRegistry);
}
/**
* Parses comma-separated exclusions from environment variable
* @param {string | undefined} envValue
* @returns {string[]}
*/
function parseExclusionsFromEnv(envValue) {
if (!envValue || typeof envValue !== "string") {
return [];
}
return envValue
.split(",")
.map((exclusion) => exclusion.trim())
.filter((exclusion) => exclusion.length > 0);
}
/**
* Gets the minimum package age exclusions from both environment variable and config file (merged)
* @returns {string[]}
*/
export function getMinimumPackageAgeExclusions() {
const envExclusions = parseExclusionsFromEnv(
environmentVariables.getMinimumPackageAgeExclusions()
);
const configExclusions = configFile.getMinimumPackageAgeExclusions();
// Merge both sources and remove duplicates
const allExclusions = [...envExclusions, ...configExclusions];
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

@ -0,0 +1,647 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
let configFileContent = undefined;
mock.module("fs", {
namedExports: {
existsSync: () => configFileContent !== undefined,
readFileSync: () => configFileContent,
writeFileSync: (content) => (configFileContent = content),
mkdirSync: () => {},
},
});
const {
getNpmCustomRegistries,
getPipCustomRegistries,
getMinimumPackageAgeExclusions,
getMalwareListBaseUrl,
setEcoSystem,
ECOSYSTEM_JS,
ECOSYSTEM_PY,
getLoggingLevel,
LOGGING_SILENT,
LOGGING_NORMAL,
LOGGING_VERBOSE,
} = await import("./settings.js");
const { initializeCliArguments } = await import("./cliArguments.js");
for (const { packageManager, getCustomRegistries, envVarName } of [
{
packageManager: "npm",
getCustomRegistries: getNpmCustomRegistries,
envVarName: "SAFE_CHAIN_NPM_CUSTOM_REGISTRIES",
},
{
packageManager: "pip",
getCustomRegistries: getPipCustomRegistries,
envVarName: "SAFE_CHAIN_PIP_CUSTOM_REGISTRIES",
},
]) {
describe(getCustomRegistries.name, async () => {
let originalEnv;
beforeEach(() => {
originalEnv = process.env[envVarName];
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env[envVarName] = originalEnv;
} else {
delete process.env[envVarName];
}
configFileContent = undefined;
});
it("should return empty array when no registries configured", () => {
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return registries without protocol", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`${packageManager}.company.com`,
"registry.internal.net",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should strip https:// protocol from registries", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`https://${packageManager}.company.com`,
"https://registry.internal.net",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should strip http:// protocol from registries", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`http://${packageManager}.company.com`,
"http://registry.internal.net",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
]);
});
it("should handle mixed protocols and no protocol", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`https://${packageManager}.company.com`,
"registry.internal.net",
"http://private.registry.io",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"registry.internal.net",
"private.registry.io",
]);
});
it("should preserve registry path after stripping protocol", () => {
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`https://${packageManager}.company.com/custom/path`,
`registry.internal.net/${packageManager}`,
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com/custom/path`,
`registry.internal.net/${packageManager}`,
]);
});
it("should parse comma-separated registries from environment variable", () => {
delete process.env[envVarName];
process.env[envVarName] = "env1.registry.com,env2.registry.net";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should trim whitespace from environment variable registries", () => {
delete process.env[envVarName];
process.env[envVarName] = " env1.registry.com , env2.registry.net ";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should merge environment variable and config file registries", () => {
delete process.env[envVarName];
process.env[envVarName] = "env1.registry.com";
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: ["config1.registry.net"],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"config1.registry.net",
]);
});
it("should remove duplicate registries when merging env and config", () => {
delete process.env[envVarName];
process.env[
envVarName
] = `${packageManager}.company.com,env.registry.com`;
configFileContent = JSON.stringify({
[packageManager]: {
customRegistries: [
`${packageManager}.company.com`,
"config.registry.net",
],
},
});
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
`${packageManager}.company.com`,
"env.registry.com",
"config.registry.net",
]);
});
it("should normalize protocols from environment variable registries", () => {
delete process.env[envVarName];
process.env[envVarName] =
"https://env1.registry.com,http://env2.registry.net";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should handle empty strings in comma-separated list", () => {
delete process.env[envVarName];
process.env[envVarName] = "env1.registry.com,,env2.registry.net,";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, [
"env1.registry.com",
"env2.registry.net",
]);
});
it("should handle single registry in environment variable", () => {
delete process.env[envVarName];
process.env[envVarName] = "single.registry.com";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, ["single.registry.com"]);
});
it("should return empty array for empty environment variable", () => {
delete process.env[envVarName];
process.env[envVarName] = "";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
it("should return empty array for whitespace-only environment variable", () => {
delete process.env[envVarName];
process.env[envVarName] = " , , ";
configFileContent = undefined;
const registries = getCustomRegistries();
assert.deepStrictEqual(registries, []);
});
});
}
describe("getLoggingLevel", () => {
let originalEnv;
beforeEach(() => {
originalEnv = process.env.SAFE_CHAIN_LOGGING;
delete process.env.SAFE_CHAIN_LOGGING;
// Reset CLI arguments state
initializeCliArguments([]);
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env.SAFE_CHAIN_LOGGING = originalEnv;
} else {
delete process.env.SAFE_CHAIN_LOGGING;
}
});
it("should return normal by default when nothing is configured", () => {
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_NORMAL);
});
it("should return silent from environment variable", () => {
process.env.SAFE_CHAIN_LOGGING = "silent";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_SILENT);
});
it("should return verbose from environment variable", () => {
process.env.SAFE_CHAIN_LOGGING = "verbose";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_VERBOSE);
});
it("should handle uppercase environment variable values", () => {
process.env.SAFE_CHAIN_LOGGING = "VERBOSE";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_VERBOSE);
});
it("should handle mixed case environment variable values", () => {
process.env.SAFE_CHAIN_LOGGING = "Silent";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_SILENT);
});
it("should return normal for invalid environment variable values", () => {
process.env.SAFE_CHAIN_LOGGING = "invalid";
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_NORMAL);
});
it("should prioritize CLI argument over environment variable", () => {
process.env.SAFE_CHAIN_LOGGING = "verbose";
initializeCliArguments(["--safe-chain-logging=silent"]);
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_SILENT);
});
it("should use environment variable when CLI argument is not set", () => {
process.env.SAFE_CHAIN_LOGGING = "silent";
initializeCliArguments(["install", "express"]);
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_SILENT);
});
it("should return normal when CLI argument is invalid (even if env var is valid)", () => {
process.env.SAFE_CHAIN_LOGGING = "verbose";
initializeCliArguments(["--safe-chain-logging=invalid"]);
const level = getLoggingLevel();
assert.strictEqual(level, LOGGING_NORMAL);
});
});
describe("getMinimumPackageAgeExclusions", () => {
let originalEnv;
let originalLegacyEnv;
const envVarName = "SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS";
const legacyEnvVarName = "SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS";
beforeEach(() => {
originalEnv = process.env[envVarName];
originalLegacyEnv = process.env[legacyEnvVarName];
delete process.env[envVarName];
delete process.env[legacyEnvVarName];
setEcoSystem(ECOSYSTEM_JS);
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env[envVarName] = originalEnv;
} else {
delete process.env[envVarName];
}
if (originalLegacyEnv !== undefined) {
process.env[legacyEnvVarName] = originalLegacyEnv;
} else {
delete process.env[legacyEnvVarName];
}
configFileContent = undefined;
});
it("should return empty array when no exclusions configured", () => {
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []);
});
it("should return exclusions from config file", () => {
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["react", "@aikidosec/safe-chain"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["react", "@aikidosec/safe-chain"]);
});
it("should parse comma-separated exclusions from environment variable", () => {
process.env[envVarName] = "lodash,express,@types/node";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "express", "@types/node"]);
});
it("should merge environment variable and config file exclusions", () => {
process.env[envVarName] = "lodash";
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["react"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
it("should remove duplicate exclusions when merging", () => {
process.env[envVarName] = "lodash,react";
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["react", "express"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react", "express"]);
});
it("should trim whitespace from environment variable exclusions", () => {
process.env[envVarName] = " lodash , react ";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
it("should handle scoped packages", () => {
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["@babel/core", "@types/react"],
},
});
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["@babel/core", "@types/react"]);
});
it("should handle empty strings in comma-separated list", () => {
process.env[envVarName] = "lodash,,react,";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, ["lodash", "react"]);
});
it("should return empty array for empty environment variable", () => {
process.env[envVarName] = "";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []);
});
it("should return empty array for whitespace-only environment variable", () => {
process.env[envVarName] = " , , ";
configFileContent = undefined;
const exclusions = getMinimumPackageAgeExclusions();
assert.deepStrictEqual(exclusions, []);
});
it("should filter non-string values from config file", () => {
configFileContent = JSON.stringify({
npm: {
minimumPackageAgeExclusions: ["react", 123, null, "lodash", undefined],
},
});
const exclusions = getMinimumPackageAgeExclusions();
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

@ -1,96 +1,122 @@
// oxlint-disable no-console
import chalk from "chalk";
import ora from "ora";
import { createInterface } from "readline";
import { isCi } from "./environment.js";
import {
getLoggingLevel,
LOGGING_SILENT,
LOGGING_VERBOSE,
} from "../config/settings.js";
/**
* @type {{ bufferOutput: boolean, bufferedMessages:(() => void)[]}}
*/
const state = {
bufferOutput: false,
bufferedMessages: [],
};
function isSilentMode() {
return getLoggingLevel() === LOGGING_SILENT;
}
function isVerboseMode() {
return getLoggingLevel() === LOGGING_VERBOSE;
}
function emptyLine() {
if (isSilentMode()) return;
writeInformation("");
}
/**
* @param {string} message
* @param {...any} optionalParams
* @returns {void}
*/
function writeInformation(message, ...optionalParams) {
console.log(message, ...optionalParams);
if (isSilentMode()) return;
writeOrBuffer(() => console.log(message, ...optionalParams));
}
/**
* @param {string} message
* @param {...any} optionalParams
* @returns {void}
*/
function writeWarning(message, ...optionalParams) {
if (isSilentMode()) return;
if (!isCi()) {
message = chalk.yellow(message);
}
console.warn(message, ...optionalParams);
writeOrBuffer(() => console.warn(message, ...optionalParams));
}
/**
* @param {string} message
* @param {...any} optionalParams
* @returns {void}
*/
function writeError(message, ...optionalParams) {
if (!isCi()) {
message = chalk.red(message);
}
console.error(message, ...optionalParams);
writeOrBuffer(() => console.error(message, ...optionalParams));
}
function startProcess(message) {
if (isCi()) {
return {
succeed: (message) => {
writeInformation(message);
},
fail: (message) => {
writeError(message);
},
stop: () => {},
setText: (message) => {
writeInformation(message);
},
};
function writeExitWithoutInstallingMaliciousPackages() {
let message = "Safe-chain: Exiting without installing malicious packages.";
if (!isCi()) {
message = chalk.red(message);
}
writeOrBuffer(() => console.error(message));
}
/**
* @param {string} message
* @param {...any} optionalParams
* @returns {void}
*/
function writeVerbose(message, ...optionalParams) {
if (!isVerboseMode()) return;
writeOrBuffer(() => console.log(message, ...optionalParams));
}
/**
*
* @param {() => void} messageFunction
*/
function writeOrBuffer(messageFunction) {
if (state.bufferOutput) {
state.bufferedMessages.push(messageFunction);
} else {
const spinner = ora(message).start();
return {
succeed: (message) => {
spinner.succeed(message);
},
fail: (message) => {
spinner.fail(message);
},
stop: () => {
spinner.stop();
},
setText: (message) => {
spinner.text = message;
},
};
messageFunction();
}
}
async function confirm(config) {
if (isCi()) {
return Promise.resolve(config.default);
function startBufferingLogs() {
state.bufferOutput = true;
state.bufferedMessages = [];
}
function writeBufferedLogsAndStopBuffering() {
state.bufferOutput = false;
for (const log of state.bufferedMessages) {
log();
}
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
const defaultText = config.default ? " (Y/n)" : " (y/N)";
rl.question(`${config.message}${defaultText} `, (answer) => {
rl.close();
const normalizedAnswer = answer.trim().toLowerCase();
if (normalizedAnswer === "y" || normalizedAnswer === "yes") {
resolve(true);
} else if (normalizedAnswer === "n" || normalizedAnswer === "no") {
resolve(false);
} else {
resolve(config.default);
}
});
});
state.bufferedMessages = [];
}
export const ui = {
writeVerbose,
writeInformation,
writeWarning,
writeError,
writeExitWithoutInstallingMaliciousPackages,
emptyLine,
startProcess,
confirm,
startBufferingLogs,
writeBufferedLogsAndStopBuffering,
};

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

@ -6,11 +6,40 @@ import { getPackageManager } from "./packagemanager/currentPackageManager.js";
import { initializeCliArguments } from "./config/cliArguments.js";
import { createSafeChainProxy } from "./registryProxy/registryProxy.js";
import chalk from "chalk";
import { getAuditStats } from "./scanning/audit/index.js";
/**
* @param {string[]} args
* @returns {Promise<number>}
*/
export async function main(args) {
if (isSafeChainVerify(args)) {
return 0;
}
process.on("SIGINT", handleProcessTermination);
process.on("SIGTERM", handleProcessTermination);
const proxy = createSafeChainProxy();
await proxy.startServer();
// Global error handlers to log unhandled errors
process.on("uncaughtException", (error) => {
ui.writeError(`Safe-chain: Uncaught exception: ${error.message}`);
ui.writeVerbose(`Stack trace: ${error.stack}`);
ui.writeBufferedLogsAndStopBuffering();
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
ui.writeError(`Safe-chain: Unhandled promise rejection: ${reason}`);
if (reason instanceof Error) {
ui.writeVerbose(`Stack trace: ${reason.stack}`);
}
ui.writeBufferedLogsAndStopBuffering();
process.exit(1);
});
try {
// This parses all the --safe-chain arguments and removes them from the args array
args = initializeCliArguments(args);
@ -25,24 +54,52 @@ export async function main(args) {
}
}
// Buffer logs during package manager execution, this avoids interleaving
// of logs from the package manager and safe-chain
// Not doing this could cause bugs to disappear when cursor movement codes
// are written by the package manager while safe-chain is writing logs
ui.startBufferingLogs();
const packageManagerResult = await getPackageManager().runCommand(args);
if (!proxy.verifyNoMaliciousPackages()) {
// Write all buffered logs
ui.writeBufferedLogsAndStopBuffering();
if (proxy.hasBlockedMaliciousPackages()) {
return 1;
}
ui.emptyLine();
ui.writeInformation(
`${chalk.green(
"✔"
)} Safe-chain: Command completed, no malicious packages found.`
);
if (proxy.hasBlockedMinimumAgeRequests()) {
return 1;
}
const auditStats = getAuditStats();
if (auditStats.totalPackages > 0) {
ui.writeVerbose(
`${chalk.green("✔")} Safe-chain: Scanned ${
auditStats.totalPackages
} packages, no malware found.`,
);
}
if (proxy.hasSuppressedVersions()) {
ui.writeInformation(
`${chalk.yellow(
"",
)} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`,
);
ui.writeInformation(
` To disable this check, use: ${chalk.cyan(
"--safe-chain-skip-minimum-package-age",
)}`,
);
}
// Returning the exit code back to the caller allows the promise
// to be awaited in the bin files and return the correct exit code
return packageManagerResult.status;
} catch (error) {
} catch (/** @type any */ error) {
ui.writeError("Failed to check for malicious packages:", error.message);
ui.writeBufferedLogsAndStopBuffering();
// Returning the exit code back to the caller allows the promise
// to be awaited in the bin files and return the correct exit code
@ -51,3 +108,16 @@ export async function main(args) {
await proxy.stopServer();
}
}
function handleProcessTermination() {
ui.writeBufferedLogsAndStopBuffering();
}
/** @param {string[]} args */
function isSafeChainVerify(args) {
const safeChainCheckCommand = "safe-chain-verify";
if (args.length > 0 && args[0] === safeChainCheckCommand) {
ui.writeInformation("OK: Safe-chain works!");
return true;
}
}

View file

@ -0,0 +1,17 @@
import { ui } from "../../environment/userInteraction.js";
/**
* Centralized logging for package-manager command launch failures.
*
* @param {any} error - Error thrown by safeSpawn while preparing/running the command.
* @param {string} command - Command name that failed to execute.
* @returns {{status: number}}
*/
export function reportCommandExecutionFailure(error, command) {
const message = typeof error?.message === "string" ? error.message : "Unknown error";
ui.writeError(`Error executing command: ${message}`);
ui.writeError(`Is '${command}' installed and available on your system?`);
return { status: typeof error?.status === "number" ? error.status : 1 };
}

View file

@ -0,0 +1,59 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("reportCommandExecutionFailure", () => {
let errorLines;
beforeEach(async () => {
errorLines = [];
mock.module("../../environment/userInteraction.js", {
namedExports: {
ui: {
writeError: (...args) => {
errorLines.push(args.join(" "));
},
},
},
});
});
afterEach(() => {
mock.reset();
});
it("reports command errors while preserving exit status", async () => {
const { reportCommandExecutionFailure } = await import("./commandErrors.js");
const result = reportCommandExecutionFailure(
{
status: 127,
message: "Command failed: command -v bun",
},
"bun",
);
assert.deepStrictEqual(result, { status: 127 });
assert.deepStrictEqual(errorLines, [
"Error executing command: Command failed: command -v bun",
"Is 'bun' installed and available on your system?",
]);
});
it("falls back to exit code 1 when status is missing", async () => {
const { reportCommandExecutionFailure } = await import("./commandErrors.js");
const result = reportCommandExecutionFailure(
{
message: "Network error",
},
"npm",
);
assert.deepStrictEqual(result, { status: 1 });
assert.deepStrictEqual(errorLines, [
"Error executing command: Network error",
"Is 'npm' installed and available on your system?",
]);
});
});

View file

@ -1,3 +1,8 @@
/**
* @param {string[]} args
* @param {...string} commandArgs
* @returns {boolean}
*/
export function matchesCommand(args, ...commandArgs) {
if (args.length < commandArgs.length) {
return false;

View file

@ -1,7 +1,10 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createBunPackageManager() {
return {
runCommand: (args) => runBunCommand("bun", args),
@ -13,6 +16,9 @@ export function createBunPackageManager() {
};
}
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createBunxPackageManager() {
return {
runCommand: (args) => runBunCommand("bunx", args),
@ -24,6 +30,11 @@ export function createBunxPackageManager() {
};
}
/**
* @param {string} command
* @param {string[]} args
* @returns {Promise<{status: number}>}
*/
async function runBunCommand(command, args) {
try {
const result = await safeSpawn(command, args, {
@ -31,12 +42,7 @@ async function runBunCommand(command, args) {
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
return { status: result.status };
} catch (error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, command);
}
}

View file

@ -9,14 +9,45 @@ import {
createPnpxPackageManager,
} from "./pnpm/createPackageManager.js";
import { createYarnPackageManager } from "./yarn/createPackageManager.js";
import { createPipPackageManager } from "./pip/createPackageManager.js";
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js";
import { createRushPackageManager } from "./rush/createRushPackageManager.js";
import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js";
import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js";
/**
* @type {{packageManagerName: PackageManager | null}}
*/
const state = {
packageManagerName: null,
};
export function initializePackageManager(packageManagerName, version) {
/**
* @typedef {Object} GetDependencyUpdatesResult
* @property {string} name
* @property {string} version
* @property {string} type
*/
/**
* @typedef {Object} PackageManager
* @property {(args: string[]) => Promise<{ status: number }>} runCommand
* @property {(args: string[]) => boolean} isSupportedCommand
* @property {(args: string[]) => Promise<GetDependencyUpdatesResult[]> | GetDependencyUpdatesResult[]} getDependencyUpdatesForCommand
*/
/**
* @param {string} packageManagerName
* @param {{ tool: string, args: string[] }} [context] - Optional tool context for package managers like pip
*
* @return {PackageManager}
*/
export function initializePackageManager(packageManagerName, context) {
if (packageManagerName === "npm") {
state.packageManagerName = createNpmPackageManager(version);
state.packageManagerName = createNpmPackageManager();
} else if (packageManagerName === "npx") {
state.packageManagerName = createNpxPackageManager();
} else if (packageManagerName === "yarn") {
@ -29,6 +60,22 @@ export function initializePackageManager(packageManagerName, version) {
state.packageManagerName = createBunPackageManager();
} else if (packageManagerName === "bunx") {
state.packageManagerName = createBunxPackageManager();
} else if (packageManagerName === "pip") {
state.packageManagerName = createPipPackageManager(context);
} else if (packageManagerName === "uv") {
state.packageManagerName = createUvPackageManager();
} else if (packageManagerName === "uvx") {
state.packageManagerName = createUvxPackageManager();
} else if (packageManagerName === "poetry") {
state.packageManagerName = createPoetryPackageManager();
} else if (packageManagerName === "pipx") {
state.packageManagerName = createPipXPackageManager();
} else if (packageManagerName === "pdm") {
state.packageManagerName = createPdmPackageManager();
} else if (packageManagerName === "rush") {
state.packageManagerName = createRushPackageManager();
} else if (packageManagerName === "rushx") {
state.packageManagerName = createRushxPackageManager();
} else {
throw new Error("Unsupported package manager: " + packageManagerName);
}

View file

@ -1,34 +1,40 @@
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
import { dryRunScanner } from "./dependencyScanner/dryRunScanner.js";
import { nullScanner } from "./dependencyScanner/nullScanner.js";
import { runNpm } from "./runNpmCommand.js";
import {
getNpmCommandForArgs,
npmInstallCommand,
npmCiCommand,
npmInstallTestCommand,
npmInstallCiTestCommand,
npmUpdateCommand,
npmAuditCommand,
npmExecCommand,
} from "./utils/npmCommands.js";
export function createNpmPackageManager(version) {
// From npm v10.4.0 onwards, the npm commands output detailed information
// when using the --dry-run flag.
// We use that information to scan for dependency changes.
// For older versions of npm we have to rely on parsing the command arguments.
const supportedScanners = isPriorToNpm10_4(version)
? npm10_3AndBelowSupportedScanners
: npm10_4AndAboveSupportedScanners;
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createNpmPackageManager() {
/**
* @param {string[]} args
*
* @returns {boolean}
*/
function isSupportedCommand(args) {
const scanner = findDependencyScannerForCommand(supportedScanners, args);
const scanner = findDependencyScannerForCommand(
commandScannerMapping,
args
);
return scanner.shouldScan(args);
}
/**
* @param {string[]} args
*
* @returns {ReturnType<import("../currentPackageManager.js").PackageManager["getDependencyUpdatesForCommand"]>}
*/
function getDependencyUpdatesForCommand(args) {
const scanner = findDependencyScannerForCommand(supportedScanners, args);
const scanner = findDependencyScannerForCommand(
commandScannerMapping,
args
);
return scanner.scan(args);
}
@ -39,40 +45,22 @@ export function createNpmPackageManager(version) {
};
}
const npm10_4AndAboveSupportedScanners = {
[npmInstallCommand]: dryRunScanner(),
[npmUpdateCommand]: dryRunScanner(),
[npmCiCommand]: dryRunScanner(),
[npmAuditCommand]: dryRunScanner({
skipScanWhen: (args) => !args.includes("fix"),
}),
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
// Running dry-run on install-test and install-ci-test will install & run tests.
// We only want to know if there are changes in the dependencies.
// So we run change the dry-run command to only check the install.
[npmInstallTestCommand]: dryRunScanner({ dryRunCommand: npmInstallCommand }),
[npmInstallCiTestCommand]: dryRunScanner({ dryRunCommand: npmCiCommand }),
};
const npm10_3AndBelowSupportedScanners = {
/**
* @type {Record<string, import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner>}
*/
const commandScannerMapping = {
[npmInstallCommand]: commandArgumentScanner(),
[npmUpdateCommand]: commandArgumentScanner(),
[npmExecCommand]: commandArgumentScanner({ ignoreDryRun: true }), // exec command doesn't support dry-run
};
function isPriorToNpm10_4(version) {
try {
const [major, minor] = version.split(".").map(Number);
if (major < 10) return true;
if (major === 10 && minor < 4) return true;
return false;
} catch {
// Default to true: if version parsing fails, assume it's an older version
return true;
}
}
/**
*
* @param {Record<string, import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner>} scanners
* @param {string[]} args
*
* @returns {import("./dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
*/
function findDependencyScannerForCommand(scanners, args) {
const command = getNpmCommandForArgs(args);
if (!command) {

View file

@ -2,6 +2,29 @@ import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromInstallArgs } from "../parsing/parsePackagesFromInstallArgs.js";
import { hasDryRunArg } from "../utils/npmCommands.js";
/**
* @typedef {Object} ScanResult
* @property {string} name
* @property {string} version
* @property {string} type
*/
/**
* @typedef {Object} ScannerOptions
* @property {boolean} [ignoreDryRun]
*/
/**
* @typedef {Object} CommandArgumentScanner
* @property {(args: string[]) => Promise<ScanResult[]> | ScanResult[]} scan
* @property {(args: string[]) => boolean} shouldScan
*/
/**
* @param {ScannerOptions} [opts]
*
* @returns {CommandArgumentScanner}
*/
export function commandArgumentScanner(opts) {
const ignoreDryRun = opts?.ignoreDryRun ?? false;
@ -10,14 +33,28 @@ export function commandArgumentScanner(opts) {
shouldScan: (args) => shouldScanDependencies(args, ignoreDryRun),
};
}
/**
* @param {string[]} args
* @returns {Promise<ScanResult[]>}
*/
function scanDependencies(args) {
return checkChangesFromArgs(args);
}
/**
* @param {string[]} args
* @param {boolean} ignoreDryRun
* @returns {boolean}
*/
function shouldScanDependencies(args, ignoreDryRun) {
return ignoreDryRun || !hasDryRunArg(args);
}
/**
* @param {string[]} args
* @returns {Promise<ScanResult[]>}
*/
export async function checkChangesFromArgs(args) {
const changes = [];
const packageUpdates = parsePackagesFromInstallArgs(args);

View file

@ -1,67 +0,0 @@
import { parseDryRunOutput } from "../parsing/parseNpmInstallDryRunOutput.js";
import { dryRunNpmCommandAndOutput } from "../runNpmCommand.js";
import { hasDryRunArg } from "../utils/npmCommands.js";
export function dryRunScanner(scannerOptions) {
return {
scan: (args) => scanDependencies(scannerOptions, args),
shouldScan: (args) => shouldScanDependencies(scannerOptions, args),
};
}
function scanDependencies(scannerOptions, args) {
let dryRunArgs = args;
if (scannerOptions?.dryRunCommand) {
// Replace the first argument with the dryRunCommand (eg: "install" instead of "install-test")
dryRunArgs = [scannerOptions.dryRunCommand, ...args.slice(1)];
}
return checkChangesWithDryRun(dryRunArgs);
}
function shouldScanDependencies(scannerOptions, args) {
if (hasDryRunArg(args)) {
return false;
}
if (scannerOptions?.skipScanWhen && scannerOptions.skipScanWhen(args)) {
return false;
}
return true;
}
async function checkChangesWithDryRun(args) {
const dryRunOutput = await dryRunNpmCommandAndOutput(args);
// Dry-run can return a non-zero status code in some cases
// e.g., when running "npm audit fix --dry-run", it returns exit code 1
// when there are vulnerabilities that can be fixed.
if (dryRunOutput.status !== 0 && !canCommandReturnNonZeroOnSuccess(args)) {
throw new Error(
`Dry-run command failed with exit code ${dryRunOutput.status} and output:\n${dryRunOutput.output}`
);
}
if (dryRunOutput.status !== 0 && !dryRunOutput.output) {
throw new Error(
`Dry-run command failed with exit code ${dryRunOutput.status} and produced no output.`
);
}
const parsedOutput = parseDryRunOutput(dryRunOutput.output);
// reverse the array to have the top-level packages first
return parsedOutput.reverse();
}
function canCommandReturnNonZeroOnSuccess(args) {
if (args.length < 2) {
return false;
}
// `npm audit fix --dry-run` can return exit code 1 when it succesfully ran and
// there were vulnerabilities that could be fixed
return args[0] === "audit" && args[1] === "fix";
}

View file

@ -1,139 +0,0 @@
import { describe, it, mock } from "node:test";
import assert from "node:assert/strict";
describe("dryRunScanner", async () => {
const mockWriteError = mock.fn();
const mockDryRunNpmCommandAndOutput = mock.fn();
// Mock ui module
mock.module("../../../environment/userInteraction.js", {
namedExports: {
ui: {
writeError: mockWriteError,
},
},
});
// Mock dryRunNpmCommandAndOutput function
mock.module("../runNpmCommand.js", {
namedExports: {
dryRunNpmCommandAndOutput: mockDryRunNpmCommandAndOutput,
},
});
const { dryRunScanner } = await import("./dryRunScanner.js");
describe("doesCommandReturnNonZero", () => {
// We need to access the internal function for testing
// Since it's not exported, we'll test it indirectly through the main functionality
it("should handle npm audit fix commands that return non-zero", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 1,
output: "found 5 vulnerabilities that can be fixed",
}));
const scanner = dryRunScanner();
const result = await scanner.scan(["audit", "fix"]);
// Should not throw an error for audit fix commands
assert.ok(Array.isArray(result));
assert.equal(mockWriteError.mock.callCount(), 0);
});
it("should throw error for unexpected non-zero exit codes", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 1,
output: "some error output",
}));
const scanner = dryRunScanner();
await assert.rejects(async () => {
await scanner.scan(["install", "lodash"]);
}, /Dry-run command failed with exit code 1/);
});
it("should handle zero exit codes normally", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 0,
output: "added 1 package",
}));
const scanner = dryRunScanner();
const result = await scanner.scan(["install", "lodash"]);
assert.ok(Array.isArray(result));
assert.equal(mockWriteError.mock.callCount(), 0);
});
it("should throw error for non-zero exit with no output for audit fix", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 1,
output: "",
}));
const scanner = dryRunScanner();
await assert.rejects(async () => {
await scanner.scan(["audit", "fix"]);
}, /Dry-run command failed with exit code 1/);
});
});
describe("scanner functionality", () => {
it("should use dryRunCommand option when provided", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
mockDryRunNpmCommandAndOutput.mock.mockImplementationOnce(() => ({
status: 0,
output: "no changes",
}));
const scanner = dryRunScanner({ dryRunCommand: "install" });
await scanner.scan(["install-test", "lodash"]);
// Should call with "install" instead of "install-test"
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1);
const calledArgs =
mockDryRunNpmCommandAndOutput.mock.calls[0].arguments[0];
assert.deepEqual(calledArgs, ["install", "lodash"]);
});
it("should skip scanning when hasDryRunArg returns true", async () => {
mockDryRunNpmCommandAndOutput.mock.resetCalls();
mockWriteError.mock.resetCalls();
const scanner = dryRunScanner();
const shouldScan = scanner.shouldScan(["install", "--dry-run"]);
assert.equal(shouldScan, false);
// Should not call dryRunNpmCommandAndOutput since scanning is skipped
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 0);
});
it("should skip scanning when skipScanWhen returns true", async () => {
const scanner = dryRunScanner({
skipScanWhen: (args) => args.includes("--skip"),
});
const shouldScan = scanner.shouldScan(["install", "--skip"]);
assert.equal(shouldScan, false);
});
it("should scan when conditions are met", async () => {
const scanner = dryRunScanner();
const shouldScan = scanner.shouldScan(["install", "lodash"]);
assert.equal(shouldScan, true);
});
});
});

View file

@ -1,3 +1,6 @@
/**
* @returns {import("./commandArgumentScanner.js").CommandArgumentScanner}
*/
export function nullScanner() {
return {
scan: () => [],

View file

@ -1,57 +0,0 @@
export function parseDryRunOutput(output) {
const lines = output.split(/\r?\n/);
const packageChanges = [];
for (const line of lines) {
if (line.startsWith("add ")) {
packageChanges.push(parseAdd(line));
} else if (line.startsWith("remove ")) {
packageChanges.push(parseRemove(line));
} else if (line.startsWith("change ")) {
packageChanges.push(parseChange(line));
}
}
return packageChanges;
}
function parseAdd(line) {
const splitLine = getLineParts(line);
const packageName = splitLine[1];
const packageVersion = splitLine[splitLine.length - 1];
return addedPackage(packageName, packageVersion);
}
function addedPackage(name, version) {
return { type: "add", name, version };
}
function parseRemove(line) {
const splitLine = getLineParts(line);
const packageName = splitLine[1];
const packageVersion = splitLine[splitLine.length - 1];
return removedPackage(packageName, packageVersion);
}
function removedPackage(name, version) {
return { type: "remove", name, version };
}
function parseChange(line) {
const splitLine = getLineParts(line);
const packageName = splitLine[1];
const packageVersion = splitLine[splitLine.length - 1];
const oldVersion = splitLine[2];
return changedPackage(packageName, packageVersion, oldVersion);
}
function getLineParts(line) {
return line
.split(" ")
.map((part) => part.trim())
.filter((part) => part !== "");
}
function changedPackage(name, version, oldVersion) {
return { type: "change", name, version, oldVersion };
}

View file

@ -1,134 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { parseDryRunOutput } from "./parseNpmInstallDryRunOutput.js";
describe("parseNpmInstallDryRunOutput", () => {
it("should parse added packages", () => {
const output = `
add @jest/transform 29.7.0
add @jest/test-result 29.7.0
add @jest/reporters 29.7.0
add @jest/console 29.7.0
add jest-cli 29.7.0
add import-local 3.2.0
add @jest/types 29.6.3
add @jest/core 29.7.0
add jest 29.7.0
added 267 packages in 831ms
32 packages are looking for funding
run \`npm fund\` for details`;
const expected = [
{ name: "@jest/transform", version: "29.7.0", type: "add" },
{ name: "@jest/test-result", version: "29.7.0", type: "add" },
{ name: "@jest/reporters", version: "29.7.0", type: "add" },
{ name: "@jest/console", version: "29.7.0", type: "add" },
{ name: "jest-cli", version: "29.7.0", type: "add" },
{ name: "import-local", version: "3.2.0", type: "add" },
{ name: "@jest/types", version: "29.6.3", type: "add" },
{ name: "@jest/core", version: "29.7.0", type: "add" },
{ name: "jest", version: "29.7.0", type: "add" },
];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
it("should parse removed packages", () => {
const output = `
remove react 19.1.0
removed 1 package in 115ms`;
const expected = [{ name: "react", version: "19.1.0", type: "remove" }];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
it("should parse changed packages", () => {
const output = `
change react 19.0.0 => 19.1.0
changed 1 package in 204ms`;
const expected = [
{
name: "react",
version: "19.1.0",
oldVersion: "19.0.0",
type: "change",
},
];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
it("should parse mixed package changes", () => {
const output = `
add @jest/transform 29.7.0
add @jest/test-result 29.7.0
add @jest/reporters 29.7.0
add @jest/console 29.7.0
add jest-cli 29.7.0
add import-local 3.2.0
add @jest/types 29.6.3
add @jest/core 29.7.0
add jest 29.7.0
remove react 19.1.0
change lodash 4.17.0 => 4.18.0
removed 1 package in 115ms`;
const expected = [
{ name: "@jest/transform", version: "29.7.0", type: "add" },
{ name: "@jest/test-result", version: "29.7.0", type: "add" },
{ name: "@jest/reporters", version: "29.7.0", type: "add" },
{ name: "@jest/console", version: "29.7.0", type: "add" },
{ name: "jest-cli", version: "29.7.0", type: "add" },
{ name: "import-local", version: "3.2.0", type: "add" },
{ name: "@jest/types", version: "29.6.3", type: "add" },
{ name: "@jest/core", version: "29.7.0", type: "add" },
{ name: "jest", version: "29.7.0", type: "add" },
{ name: "react", version: "19.1.0", type: "remove" },
{
name: "lodash",
version: "4.18.0",
oldVersion: "4.17.0",
type: "change",
},
];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
it("should work with npm v22.0.0", () => {
const output = `
add @jest/types 29.6.3
add @jest/core 29.7.0
add jest 29.7.0
added 257 packages in 791ms
44 packages are looking for funding
run \`npm fund\` for details`;
const expected = [
{ name: "@jest/types", version: "29.6.3", type: "add" },
{ name: "@jest/core", version: "29.7.0", type: "add" },
{ name: "jest", version: "29.7.0", type: "add" },
];
const result = parseDryRunOutput(output);
assert.deepEqual(result, expected);
});
});

View file

@ -1,5 +1,22 @@
/**
* @typedef {Object} PackageDetail
* @property {string} name
* @property {string} version
*/
/**
* @typedef {Object} NpmOption
* @property {string} name
* @property {number} numberOfParameters
*/
/**
* @param {string[]} args
* @returns {PackageDetail[]}
*/
export function parsePackagesFromInstallArgs(args) {
const changes = [];
/** @type {{name: string, version: string | null}[]} */
const changes = [];
let defaultTag = "latest";
// Skip first argument (install command)
@ -32,9 +49,13 @@ export function parsePackagesFromInstallArgs(args) {
}
}
return changes;
return /** @type {PackageDetail[]} */ (changes);
}
/**
* @param {string} arg
* @returns {NpmOption | undefined}
*/
function getNpmOption(arg) {
if (isNpmOptionWithParameter(arg)) {
return {
@ -54,6 +75,10 @@ function getNpmOption(arg) {
return undefined;
}
/**
* @param {string} arg
* @returns {boolean}
*/
function isNpmOptionWithParameter(arg) {
const optionsWithParameters = [
"--access",
@ -81,6 +106,10 @@ function isNpmOptionWithParameter(arg) {
return optionsWithParameters.includes(arg);
}
/**
* @param {string} arg
* @returns {{name: string, version: string | null}}
*/
function parsePackagename(arg) {
arg = removeAlias(arg);
const lastAtIndex = arg.lastIndexOf("@");
@ -102,6 +131,10 @@ function parsePackagename(arg) {
};
}
/**
* @param {string} arg
* @returns {string}
*/
function removeAlias(arg) {
const aliasIndex = arg.indexOf("@npm:");
if (aliasIndex !== -1) {

View file

@ -1,7 +1,12 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {string[]} args
*
* @returns {Promise<{status: number}>}
*/
export async function runNpm(args) {
try {
const result = await safeSpawn("npm", args, {
@ -9,41 +14,7 @@ export async function runNpm(args) {
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
return { status: result.status };
} catch (error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
}
}
export async function dryRunNpmCommandAndOutput(args) {
try {
const result = await safeSpawn(
"npm",
[...args, "--ignore-scripts", "--dry-run"],
{
stdio: "pipe",
env: mergeSafeChainProxyEnvironmentVariables(process.env),
}
);
return {
status: result.status,
output: result.status === 0 ? result.stdout : result.stderr,
};
} catch (error) {
if (error.status) {
const output =
error.stdout?.toString() ??
error.stderr?.toString() ??
error.message ??
"";
return { status: error.status, output };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, "npm");
}
}

View file

@ -0,0 +1,359 @@
// This was ran with the abbrev package to generate the abbrevs object below
// console.log(abbrev(commands.concat(Object.keys(aliases))));
/** @type {Record<string, string>} */
export const abbrevs = {
ac: "access",
acc: "access",
acce: "access",
acces: "access",
access: "access",
add: "add",
"add-": "add-user",
"add-u": "add-user",
"add-us": "add-user",
"add-use": "add-user",
"add-user": "add-user",
addu: "adduser",
addus: "adduser",
adduse: "adduser",
adduser: "adduser",
aud: "audit",
audi: "audit",
audit: "audit",
aut: "author",
auth: "author",
autho: "author",
author: "author",
b: "bugs",
bu: "bugs",
bug: "bugs",
bugs: "bugs",
c: "c",
ca: "cache",
cac: "cache",
cach: "cache",
cache: "cache",
ci: "ci",
cit: "cit",
"clean-install": "clean-install",
"clean-install-": "clean-install-test",
"clean-install-t": "clean-install-test",
"clean-install-te": "clean-install-test",
"clean-install-tes": "clean-install-test",
"clean-install-test": "clean-install-test",
com: "completion",
comp: "completion",
compl: "completion",
comple: "completion",
complet: "completion",
completi: "completion",
completio: "completion",
completion: "completion",
con: "config",
conf: "config",
confi: "config",
config: "config",
cr: "create",
cre: "create",
crea: "create",
creat: "create",
create: "create",
dd: "ddp",
ddp: "ddp",
ded: "dedupe",
dedu: "dedupe",
dedup: "dedupe",
dedupe: "dedupe",
dep: "deprecate",
depr: "deprecate",
depre: "deprecate",
deprec: "deprecate",
depreca: "deprecate",
deprecat: "deprecate",
deprecate: "deprecate",
dif: "diff",
diff: "diff",
"dist-tag": "dist-tag",
"dist-tags": "dist-tags",
docs: "docs",
doct: "doctor",
docto: "doctor",
doctor: "doctor",
ed: "edit",
edi: "edit",
edit: "edit",
exe: "exec",
exec: "exec",
expla: "explain",
explai: "explain",
explain: "explain",
explo: "explore",
explor: "explore",
explore: "explore",
find: "find",
"find-": "find-dupes",
"find-d": "find-dupes",
"find-du": "find-dupes",
"find-dup": "find-dupes",
"find-dupe": "find-dupes",
"find-dupes": "find-dupes",
fu: "fund",
fun: "fund",
fund: "fund",
g: "get",
ge: "get",
get: "get",
help: "help",
"help-": "help-search",
"help-s": "help-search",
"help-se": "help-search",
"help-sea": "help-search",
"help-sear": "help-search",
"help-searc": "help-search",
"help-search": "help-search",
hl: "hlep",
hle: "hlep",
hlep: "hlep",
ho: "home",
hom: "home",
home: "home",
i: "i",
ic: "ic",
in: "in",
inf: "info",
info: "info",
ini: "init",
init: "init",
inn: "innit",
inni: "innit",
innit: "innit",
ins: "ins",
inst: "inst",
insta: "insta",
instal: "instal",
install: "install",
"install-ci": "install-ci-test",
"install-ci-": "install-ci-test",
"install-ci-t": "install-ci-test",
"install-ci-te": "install-ci-test",
"install-ci-tes": "install-ci-test",
"install-ci-test": "install-ci-test",
"install-cl": "install-clean",
"install-cle": "install-clean",
"install-clea": "install-clean",
"install-clean": "install-clean",
"install-t": "install-test",
"install-te": "install-test",
"install-tes": "install-test",
"install-test": "install-test",
isnt: "isnt",
isnta: "isnta",
isntal: "isntal",
isntall: "isntall",
"isntall-": "isntall-clean",
"isntall-c": "isntall-clean",
"isntall-cl": "isntall-clean",
"isntall-cle": "isntall-clean",
"isntall-clea": "isntall-clean",
"isntall-clean": "isntall-clean",
iss: "issues",
issu: "issues",
issue: "issues",
issues: "issues",
it: "it",
la: "la",
lin: "link",
link: "link",
lis: "list",
list: "list",
ll: "ll",
ln: "ln",
logi: "login",
login: "login",
logo: "logout",
logou: "logout",
logout: "logout",
ls: "ls",
og: "ogr",
ogr: "ogr",
or: "org",
org: "org",
ou: "outdated",
out: "outdated",
outd: "outdated",
outda: "outdated",
outdat: "outdated",
outdate: "outdated",
outdated: "outdated",
ow: "owner",
own: "owner",
owne: "owner",
owner: "owner",
pa: "pack",
pac: "pack",
pack: "pack",
pi: "ping",
pin: "ping",
ping: "ping",
pk: "pkg",
pkg: "pkg",
pre: "prefix",
pref: "prefix",
prefi: "prefix",
prefix: "prefix",
pro: "profile",
prof: "profile",
profi: "profile",
profil: "profile",
profile: "profile",
pru: "prune",
prun: "prune",
prune: "prune",
pu: "publish",
pub: "publish",
publ: "publish",
publi: "publish",
publis: "publish",
publish: "publish",
q: "query",
qu: "query",
que: "query",
quer: "query",
query: "query",
r: "r",
rb: "rb",
reb: "rebuild",
rebu: "rebuild",
rebui: "rebuild",
rebuil: "rebuild",
rebuild: "rebuild",
rem: "remove",
remo: "remove",
remov: "remove",
remove: "remove",
rep: "repo",
repo: "repo",
res: "restart",
rest: "restart",
resta: "restart",
restar: "restart",
restart: "restart",
rm: "rm",
ro: "root",
roo: "root",
root: "root",
rum: "rum",
run: "run",
"run-": "run-script",
"run-s": "run-script",
"run-sc": "run-script",
"run-scr": "run-script",
"run-scri": "run-script",
"run-scrip": "run-script",
"run-script": "run-script",
s: "s",
sb: "sbom",
sbo: "sbom",
sbom: "sbom",
se: "se",
sea: "search",
sear: "search",
searc: "search",
search: "search",
set: "set",
sho: "show",
show: "show",
shr: "shrinkwrap",
shri: "shrinkwrap",
shrin: "shrinkwrap",
shrink: "shrinkwrap",
shrinkw: "shrinkwrap",
shrinkwr: "shrinkwrap",
shrinkwra: "shrinkwrap",
shrinkwrap: "shrinkwrap",
si: "sit",
sit: "sit",
star: "star",
stars: "stars",
start: "start",
sto: "stop",
stop: "stop",
t: "t",
tea: "team",
team: "team",
tes: "test",
test: "test",
to: "token",
tok: "token",
toke: "token",
token: "token",
ts: "tst",
tst: "tst",
ud: "udpate",
udp: "udpate",
udpa: "udpate",
udpat: "udpate",
udpate: "udpate",
un: "un",
und: "undeprecate",
unde: "undeprecate",
undep: "undeprecate",
undepr: "undeprecate",
undepre: "undeprecate",
undeprec: "undeprecate",
undepreca: "undeprecate",
undeprecat: "undeprecate",
undeprecate: "undeprecate",
uni: "uninstall",
unin: "uninstall",
unins: "uninstall",
uninst: "uninstall",
uninsta: "uninstall",
uninstal: "uninstall",
uninstall: "uninstall",
unl: "unlink",
unli: "unlink",
unlin: "unlink",
unlink: "unlink",
unp: "unpublish",
unpu: "unpublish",
unpub: "unpublish",
unpubl: "unpublish",
unpubli: "unpublish",
unpublis: "unpublish",
unpublish: "unpublish",
uns: "unstar",
unst: "unstar",
unsta: "unstar",
unstar: "unstar",
up: "up",
upd: "update",
upda: "update",
updat: "update",
update: "update",
upg: "upgrade",
upgr: "upgrade",
upgra: "upgrade",
upgrad: "upgrade",
upgrade: "upgrade",
ur: "urn",
urn: "urn",
v: "v",
veri: "verison",
veris: "verison",
veriso: "verison",
verison: "verison",
vers: "version",
versi: "version",
versio: "version",
version: "version",
vi: "view",
vie: "view",
view: "view",
who: "whoami",
whoa: "whoami",
whoam: "whoami",
whoami: "whoami",
why: "why",
x: "x",
};

View file

@ -1,6 +1,6 @@
// Based on https://github.com/npm/cli/blob/latest/lib/utils/cmd-list.js
import abbrev from "abbrev";
import { abbrevs } from "./abbrevs-generated.js";
const commands = [
"access",
@ -73,6 +73,7 @@ const commands = [
];
// These must resolve to an entry in commands
/** @type {Record<string, string>} */
const aliases = {
// aliases
author: "owner",
@ -138,6 +139,10 @@ const aliases = {
"add-user": "adduser",
};
/**
* @param {string} c
* @returns {string | undefined}
*/
export function deref(c) {
if (!c) {
return;
@ -158,8 +163,6 @@ export function deref(c) {
return aliases[c];
}
const abbrevs = abbrev(commands.concat(Object.keys(aliases)));
// first deref the abbrev, if there is one
// then resolve any aliases
// so `npm install-cl` will resolve to `install-clean` then to `ci`

View file

@ -1,5 +1,9 @@
import { deref } from "./cmd-list.js";
/**
* @param {string[]} args
* @returns {string | null}
*/
export function getNpmCommandForArgs(args) {
if (args.length === 0) {
return null;
@ -13,6 +17,10 @@ export function getNpmCommandForArgs(args) {
return argCommand;
}
/**
* @param {string[]} args
* @returns {boolean}
*/
export function hasDryRunArg(args) {
return args.some((arg) => arg === "--dry-run");
}

View file

@ -1,6 +1,9 @@
import { commandArgumentScanner } from "./dependencyScanner/commandArgumentScanner.js";
import { runNpx } from "./runNpxCommand.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createNpxPackageManager() {
const scanner = commandArgumentScanner();

View file

@ -1,16 +1,28 @@
import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js";
/**
* @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
*/
export function commandArgumentScanner() {
return {
scan: (args) => scanDependencies(args),
shouldScan: () => true, // all npx commands need to be scanned, npx doesn't have dry-run
};
}
/**
* @param {string[]} args
* @returns {Promise<import("../../npm/dependencyScanner/commandArgumentScanner.js").ScanResult[]>}
*/
function scanDependencies(args) {
return checkChangesFromArgs(args);
}
/**
* @param {string[]} args
* @returns {Promise<import("../../npm/dependencyScanner/commandArgumentScanner.js").ScanResult[]>}
*/
export async function checkChangesFromArgs(args) {
const changes = [];
const packageUpdates = parsePackagesFromArguments(args);

View file

@ -1,3 +1,8 @@
/**
* @param {string[]} args
*
* @returns {{name: string, version: string}[]}
*/
export function parsePackagesFromArguments(args) {
let defaultTag = "latest";
@ -21,6 +26,10 @@ export function parsePackagesFromArguments(args) {
return [];
}
/**
* @param {string} arg
* @returns {{name: string, numberOfParameters: number} | undefined}
*/
function getOption(arg) {
if (isOptionWithParameter(arg)) {
return {
@ -41,6 +50,10 @@ function getOption(arg) {
return undefined;
}
/**
* @param {string} arg
* @returns {boolean}
*/
function isOptionWithParameter(arg) {
const optionsWithParameters = [
"--access",
@ -68,6 +81,11 @@ function isOptionWithParameter(arg) {
return optionsWithParameters.includes(arg);
}
/**
* @param {string} arg
* @param {string} defaultTag
* @returns {{name: string, version: string}}
*/
function parsePackagename(arg, defaultTag) {
// format can be --package=name@version
// in that case, we need to remove the --package= part
@ -97,6 +115,10 @@ function parsePackagename(arg, defaultTag) {
};
}
/**
* @param {string} arg
* @returns {string}
*/
function removeAlias(arg) {
// removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest

View file

@ -1,7 +1,12 @@
import { ui } from "../../environment/userInteraction.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {string[]} args
*
* @returns {Promise<{status: number}>}
*/
export async function runNpx(args) {
try {
const result = await safeSpawn("npx", args, {
@ -9,12 +14,7 @@ export async function runNpx(args) {
env: mergeSafeChainProxyEnvironmentVariables(process.env),
});
return { status: result.status };
} catch (error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, "npx");
}
}

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,25 @@
import { runPip } from "./runPipCommand.js";
import { PIP_COMMAND } from "./pipSettings.js";
/**
* @param {{ tool: string, args: string[] }} [context] - Optional context with tool name and args
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPipPackageManager(context) {
const tool = context?.tool || PIP_COMMAND;
return {
/**
* @param {string[]} args
*/
runCommand: (args) => {
// Args from main.js are already stripped of --safe-chain-* flags
// We just pass the tool (e.g. "python3") and the args (e.g. ["-m", "pip", "install", ...])
return runPip(tool, args);
},
// For pip, rely solely on MITM proxy to detect/deny downloads from known registries.
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}

View file

@ -0,0 +1,57 @@
import { test } from "node:test";
import assert from "node:assert";
import { createPipPackageManager } from "./createPackageManager.js";
test("createPipPackageManager", async (t) => {
await t.test("should create package manager with required interface", () => {
const pm = createPipPackageManager();
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 accept pip3 as command parameter", () => {
const pm = createPipPackageManager("pip3");
assert.ok(pm);
});
await t.test("should support install, download, and wheel commands", () => {
const pm = createPipPackageManager();
// MITM-only approach, pip does not scan args
assert.strictEqual(pm.isSupportedCommand(["install", "requests"]), false);
assert.strictEqual(pm.isSupportedCommand(["download", "requests"]), false);
assert.strictEqual(pm.isSupportedCommand(["wheel", "requests"]), false);
});
await t.test("should not support uninstall and info commands", () => {
const pm = createPipPackageManager();
assert.strictEqual(pm.isSupportedCommand(["uninstall", "requests"]), false);
assert.strictEqual(pm.isSupportedCommand(["list"]), false);
assert.strictEqual(pm.isSupportedCommand(["show", "requests"]), false);
});
await t.test("should extract packages from install command", () => {
const pm = createPipPackageManager();
const result = pm.getDependencyUpdatesForCommand(["install", "requests==2.28.0"]);
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 0);
});
await t.test("should return empty array for unsupported commands", () => {
const pm = createPipPackageManager();
const result = pm.getDependencyUpdatesForCommand(["uninstall", "requests"]);
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 0);
});
await t.test("should handle empty args gracefully", () => {
const pm = createPipPackageManager();
assert.strictEqual(pm.isSupportedCommand([]), false);
assert.deepStrictEqual(pm.getDependencyUpdatesForCommand([]), []);
});
});

View file

@ -0,0 +1,6 @@
export const PIP_PACKAGE_MANAGER = "pip";
export const PIP_COMMAND = "pip";
export const PIP3_COMMAND = "pip3";
export const PYTHON_COMMAND = "python";
export const PYTHON3_COMMAND = "python3";

View file

@ -0,0 +1,209 @@
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 { PIP_COMMAND, PIP3_COMMAND, PYTHON_COMMAND, PYTHON3_COMMAND } from "./pipSettings.js";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import ini from "ini";
import { spawn } from "child_process";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* Checks if this pip invocation should bypass safe-chain and spawn directly.
* Returns true if the tool is python/python3 but NOT being run with -m pip/pip3.
* @param {string} command - The command executable
* @param {string[]} args - The arguments
* @returns {boolean}
*/
export function shouldBypassSafeChain(command, args) {
if (command === PYTHON_COMMAND || command === PYTHON3_COMMAND) {
// Check if args start with -m pip
if (args.length >= 2 && args[0] === "-m" && (args[1] === PIP_COMMAND || args[1] === PIP3_COMMAND)) {
return false;
}
return true;
}
return false;
}
/**
* Sets fallback CA bundle environment variables used by Python libraries.
* These are applied in addition to the PIP_CONFIG_FILE to ensure all Python
* network libraries respect the combined CA bundle, even if they don't read pip's config.
*
* @param {NodeJS.ProcessEnv} env - Environment object to modify
* @param {string} combinedCaPath - Path to the combined CA bundle
*/
function setFallbackCaBundleEnvironmentVariables(env, combinedCaPath) {
// REQUESTS_CA_BUNDLE: Used by the popular 'requests' library
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;
// SSL_CERT_FILE: Used by some Python SSL libraries and urllib
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;
// PIP_CERT: Pip's own environment variable for certificate verification
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 pip command with safe-chain's certificate bundle and proxy configuration.
*
* Creates a temporary pip config file to configure:
* - Cert bundle for HTTPS verification
* - Proxy settings
*
* If the user has an existing PIP_CONFIG_FILE, a new temporary config is created that merges
* their settings with safe-chain's, leaving the original file unchanged.
*
* Special handling for commands that modify config/cache/state: PIP_CONFIG_FILE is NOT overridden to allow
* users to read/write persistent config. Only CA environment variables are set for these commands.
*
* @param {string} command - The pip command executable (e.g., 'pip3' or 'python3')
* @param {string[]} args - Command line arguments to pass to pip
* @returns {Promise<{status: number}>} Exit status of the pip command
*/
export async function runPip(command, args) {
// Check if we should bypass safe-chain (python/python3 without -m pip)
if (shouldBypassSafeChain(command, args)) {
ui.writeVerbose(`Safe-chain: Bypassing safe-chain for non-pip invocation: ${command} ${args.join(" ")}`);
// Spawn the ORIGINAL command with ORIGINAL args
return new Promise((_resolve) => {
const proc = spawn(command, args, { stdio: "inherit" });
proc.on("exit", (/** @type {number | null} */ code) => {
ui.writeVerbose(`${command} ${args.join(" ")} exited with status ${code}`);
ui.writeBufferedLogsAndStopBuffering();
process.exit(code ?? 0);
});
proc.on("error", (/** @type {Error} */ err) => {
ui.writeError(`Error executing command: ${err.message}`);
ui.writeBufferedLogsAndStopBuffering();
process.exit(1);
});
});
}
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
// Always provide Python with a complete CA bundle (Safe Chain CA + Mozilla + Node built-in roots + user certs)
// so that any network request made by pip, including those outside explicit CLI args,
// validates correctly under both MITM'd and tunneled HTTPS.
const combinedCaPath = getCombinedCaBundlePath();
// Commands that need access to persistent config/cache/state files
// These should not have PIP_CONFIG_FILE overridden as it would prevent them from
// reading/writing to the user's actual pip configuration and cache directories
const configRelatedCommands = ['config', 'cache', 'debug', 'completion'];
const isConfigRelatedCommand = args.length > 0 && configRelatedCommands.includes(args[0]);
// https://pip.pypa.io/en/stable/topics/https-certificates/ explains that the 'cert' param (which we're providing via INI file)
// will tell pip to use the provided CA bundle for HTTPS verification.
// Proxy settings: GLOBAL_AGENT_HTTP_PROXY is our safe-chain proxy (if active),
// otherwise fall back to user-defined HTTPS_PROXY or HTTP_PROXY environment variables
const proxy = env.GLOBAL_AGENT_HTTP_PROXY || env.HTTPS_PROXY || env.HTTP_PROXY || '';
const tmpDir = os.tmpdir();
const pipConfigPath = path.join(tmpDir, `safe-chain-pip-${Date.now()}.ini`);
let cleanupConfigPath = null; // Track temp file for cleanup
if (isConfigRelatedCommand) {
ui.writeVerbose(`Safe-chain: Skipping PIP_CONFIG_FILE override for 'pip ${args[0]}' command to allow persistent config/cache access.`);
// Still set the fallback CA bundle environment variables to avoid edge cases where a
// plugin or extension triggers a network call during config introspection
// This can do no harm
setFallbackCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn(command, args, {
stdio: "inherit",
env,
});
return { status: result.status };
}
// Note: Setting PIP_CONFIG_FILE overrides all pip config levels (Global/User/Site) per pip's loading order
if (!env.PIP_CONFIG_FILE) {
/** @type {{ global: { cert: string, proxy?: string } }} */
const configObj = { global: { cert: combinedCaPath } };
if (proxy) {
configObj.global.proxy = proxy;
}
const pipConfig = ini.stringify(configObj);
await fs.writeFile(pipConfigPath, pipConfig);
env.PIP_CONFIG_FILE = pipConfigPath;
cleanupConfigPath = pipConfigPath;
} else if (fsSync.existsSync(env.PIP_CONFIG_FILE)) {
ui.writeVerbose("Safe-chain: Merging user provided PIP_CONFIG_FILE with safe-chain certificate and proxy settings.");
const userConfig = env.PIP_CONFIG_FILE;
// Read the existing config without modifying it
let content = await fs.readFile(userConfig, "utf-8");
const parsed = ini.parse(content);
// Ensure [global] section exists
parsed.global = parsed.global || {};
// Cert
if (typeof parsed.global.cert !== "undefined") {
ui.writeWarning("Safe-chain: User defined cert found in PIP_CONFIG_FILE. It will be overwritten in the temporary config.");
}
parsed.global.cert = combinedCaPath;
// Proxy
if (typeof parsed.global.proxy !== "undefined") {
ui.writeWarning("Safe-chain: User defined proxy found in PIP_CONFIG_FILE. It will be overwritten in the temporary config.");
}
if (proxy) {
parsed.global.proxy = proxy;
}
const updated = ini.stringify(parsed);
// Save to a new temp file to avoid overwriting user's original config
await fs.writeFile(pipConfigPath, updated, "utf-8");
env.PIP_CONFIG_FILE = pipConfigPath;
cleanupConfigPath = pipConfigPath;
} else {
// The user provided PIP_CONFIG_FILE does not exist on disk
// PIP will handle this as an error and inform the user
}
// Set fallback CA bundle environment variables for Python libraries that don't read pip config
setFallbackCaBundleEnvironmentVariables(env, combinedCaPath);
const result = await safeSpawn(command, args, {
stdio: "inherit",
env,
});
// Cleanup temporary config file if we created one
if (cleanupConfigPath) {
try {
await fs.unlink(cleanupConfigPath);
} catch {
// Ignore cleanup errors - the file may have already been deleted or is inaccessible
// Temp files in os.tmpdir() may eventually be cleaned by the OS, but timing varies by platform
}
}
return { status: result.status };
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, command);
}
}

View file

@ -0,0 +1,419 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ini from "ini";
describe("runPipCommand environment variable handling", () => {
let runPip;
let shouldBypassSafeChain;
let capturedArgs = null;
let customEnv = null;
let capturedConfigContent = null; // Capture config file content before cleanup
beforeEach(async () => {
capturedArgs = null;
capturedConfigContent = null;
// Mock safeSpawn to capture args and config file content before cleanup
mock.module("../../utils/safeSpawn.js", {
namedExports: {
safeSpawn: async (command, args, options) => {
capturedArgs = { command, args, options };
// Capture the config file content before the function cleans it up
if (options.env.PIP_CONFIG_FILE) {
try {
capturedConfigContent = await fs.readFile(options.env.PIP_CONFIG_FILE, "utf-8");
} catch {
// Ignore if file doesn't exist or can't be read
}
}
return { status: 0 };
},
},
});
// Mock proxy env merge, allow custom env override
mock.module("../../registryProxy/registryProxy.js", {
namedExports: {
mergeSafeChainProxyEnvironmentVariables: (env) => ({
...env,
...customEnv,
// Force deterministic proxy for tests regardless of ambient env
GLOBAL_AGENT_HTTP_PROXY: "http://localhost:8080",
HTTPS_PROXY: "http://localhost:8080",
HTTP_PROXY: "",
}),
},
});
// Mock certBundle to return a test combined bundle path
mock.module("../../registryProxy/certBundle.js", {
namedExports: {
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
},
});
const mod = await import("./runPipCommand.js");
runPip = mod.runPip;
shouldBypassSafeChain = mod.shouldBypassSafeChain;
});
afterEach(() => {
mock.reset();
});
it("should NOT set PIP_CONFIG_FILE for 'pip config' commands to allow persistent config access", async () => {
const res = await runPip("pip3", ["config", "set", "global.index-url", "https://test.pypi.org/simple"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called");
// PIP_CONFIG_FILE should NOT be set for config commands
assert.strictEqual(
capturedArgs.options.env.PIP_CONFIG_FILE,
undefined,
"PIP_CONFIG_FILE should NOT be set for pip config commands"
);
// But CA environment variables should still be set
assert.strictEqual(
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
"/tmp/test-combined-ca.pem",
"REQUESTS_CA_BUNDLE should still be set"
);
assert.strictEqual(
capturedArgs.options.env.SSL_CERT_FILE,
"/tmp/test-combined-ca.pem",
"SSL_CERT_FILE should still be set"
);
assert.strictEqual(
capturedArgs.options.env.PIP_CERT,
"/tmp/test-combined-ca.pem",
"PIP_CERT should still be set"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip config get' commands", async () => {
const res = await runPip("pip3", ["config", "get", "global.index-url"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called");
assert.strictEqual(
capturedArgs.options.env.PIP_CONFIG_FILE,
undefined,
"PIP_CONFIG_FILE should NOT be set for pip config get"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip config list' commands", async () => {
const res = await runPip("pip3", ["config", "list"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called");
assert.strictEqual(
capturedArgs.options.env.PIP_CONFIG_FILE,
undefined,
"PIP_CONFIG_FILE should NOT be set for pip config list"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip cache' commands", async () => {
const res = await runPip("pip3", ["cache", "dir"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called");
assert.strictEqual(
capturedArgs.options.env.PIP_CONFIG_FILE,
undefined,
"PIP_CONFIG_FILE should NOT be set for pip cache commands"
);
// CA env vars should still be set
assert.strictEqual(
capturedArgs.options.env.SSL_CERT_FILE,
"/tmp/test-combined-ca.pem",
"SSL_CERT_FILE should still be set"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip debug' commands", async () => {
const res = await runPip("pip3", ["debug"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called");
assert.strictEqual(
capturedArgs.options.env.PIP_CONFIG_FILE,
undefined,
"PIP_CONFIG_FILE should NOT be set for pip debug"
);
});
it("should NOT set PIP_CONFIG_FILE for 'pip completion' commands", async () => {
const res = await runPip("pip3", ["completion", "--bash"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called");
assert.strictEqual(
capturedArgs.options.env.PIP_CONFIG_FILE,
undefined,
"PIP_CONFIG_FILE should NOT be set for pip completion"
);
});
it("should set PIP_CERT env var and create config file", async () => {
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called");
// Check PIP_CERT env var
assert.strictEqual(
capturedArgs.options.env.PIP_CERT,
"/tmp/test-combined-ca.pem",
"PIP_CERT should be set to combined bundle path"
);
// Check PIP_CONFIG_FILE env var exists and is a non-empty string
const configPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.ok(configPath, "PIP_CONFIG_FILE should be set");
assert.strictEqual(typeof configPath, "string", "PIP_CONFIG_FILE should be a string");
assert.ok(configPath.length > 0, "PIP_CONFIG_FILE should be a non-empty path");
});
it("should set REQUESTS_CA_BUNDLE and SSL_CERT_FILE for default PyPI (no explicit index)", async () => {
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedArgs, "safeSpawn should have been called");
// Check environment variables are set
assert.strictEqual(
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
"/tmp/test-combined-ca.pem",
"REQUESTS_CA_BUNDLE should be set to combined bundle path"
);
assert.strictEqual(
capturedArgs.options.env.SSL_CERT_FILE,
"/tmp/test-combined-ca.pem",
"SSL_CERT_FILE should be set to combined bundle path"
);
});
it("should set CA environment variables even for external/test PyPI mirror (covers non-CLI traffic)", async () => {
const res = await runPip("pip3", [
"install",
"certifi",
"--index-url",
"https://test.pypi.org/simple",
]);
assert.strictEqual(res.status, 0);
// Env vars should be set unconditionally
assert.strictEqual(
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
"/tmp/test-combined-ca.pem"
);
assert.strictEqual(
capturedArgs.options.env.SSL_CERT_FILE,
"/tmp/test-combined-ca.pem"
);
});
it("should still set CA env vars for PyPI even with user --cert flag", async () => {
// For default PyPI, we still set env vars; pip CLI --cert takes precedence
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
// Environment variables still set (pip CLI --cert takes precedence)
assert.strictEqual(
capturedArgs.options.env.REQUESTS_CA_BUNDLE,
"/tmp/test-combined-ca.pem"
);
assert.strictEqual(
capturedArgs.options.env.SSL_CERT_FILE,
"/tmp/test-combined-ca.pem"
);
});
it("should preserve HTTPS_PROXY from proxy merge", async () => {
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
assert.strictEqual(
capturedArgs.options.env.HTTPS_PROXY,
"http://localhost:8080",
"HTTPS_PROXY should be set by proxy merge"
);
});
it("should create a new temp config when existing config exists (original file untouched)", async () => {
const tmpDir = os.tmpdir();
const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`);
const initial = "[global]\nindex-url = https://example.com/simple\n";
await fs.writeFile(userCfgPath, initial, "utf-8");
customEnv = { PIP_CONFIG_FILE: userCfgPath };
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.notStrictEqual(newCfgPath, userCfgPath, "should point to a new temp config file");
// Original file unchanged
const originalContent = await fs.readFile(userCfgPath, "utf-8");
const originalParsed = ini.parse(originalContent);
assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert");
// New file has merged settings (read from captured content before cleanup)
assert.ok(capturedConfigContent, "config content should have been captured");
const newParsed = ini.parse(capturedConfigContent);
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "new config should include cert");
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "new config should include proxy from env");
assert.strictEqual(newParsed.global["index-url"], "https://example.com/simple", "index-url should be preserved");
customEnv = null;
});
it("should create new config with proxy set from env (ini-validated)", async () => {
// No PIP_CONFIG_FILE in env => creation path
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
assert.ok(capturedConfigContent, "config content should have been captured");
const parsed = ini.parse(capturedConfigContent);
assert.ok(parsed.global, "[global] should exist after creation");
assert.strictEqual(
parsed.global.proxy,
"http://localhost:8080",
"proxy should be set from merged env"
);
assert.strictEqual(
parsed.global.cert,
"/tmp/test-combined-ca.pem",
"cert should be set during creation"
);
});
it("should create new temp config adding cert but preserving existing proxy (original file unchanged)", async () => {
const tmpDir = os.tmpdir();
const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`);
const initial = "[global]\nproxy = http://original:9999\n";
await fs.writeFile(userCfgPath, initial, "utf-8");
customEnv = { PIP_CONFIG_FILE: userCfgPath };
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.notStrictEqual(newCfgPath, userCfgPath, "should use a new temp config file");
// Original file unchanged
const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8"));
assert.strictEqual(originalParsed.global.cert, undefined, "original file should not gain cert");
assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy remains");
// New file: cert and proxy always overwritten (read from captured content)
assert.ok(capturedConfigContent, "config content should have been captured");
const newParsed = ini.parse(capturedConfigContent);
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config");
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config");
customEnv = null;
});
it("should create new temp config preserving existing cert and proxy while leaving original file unchanged", async () => {
const tmpDir = os.tmpdir();
const cfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`);
const initialIni = [
"[global]",
"cert = /path/to/existing.pem",
"proxy = http://original:9999",
""
].join("\n");
await fs.writeFile(cfgPath, initialIni, "utf-8");
customEnv = { PIP_CONFIG_FILE: cfgPath };
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0, "execution should succeed");
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.notStrictEqual(newCfgPath, cfgPath, "should use a newly generated temp config file");
// Original file stays untouched
const originalContent = await fs.readFile(cfgPath, "utf-8");
const originalParsed = ini.parse(originalContent);
assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert preserved");
assert.strictEqual(originalParsed.global.proxy, "http://original:9999", "original proxy preserved");
// New temp config: cert and proxy always overwritten (read from captured content)
assert.ok(capturedConfigContent, "config content should have been captured");
const newParsed = ini.parse(capturedConfigContent);
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config");
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config");
customEnv = null;
});
it("should create new temp config preserving existing cert and adding missing proxy", async () => {
const tmpDir = os.tmpdir();
const userCfgPath = path.join(tmpDir, `safe-chain-test-pip-${Date.now()}.ini`);
const initial = "[global]\ncert = /path/to/existing.pem\n";
await fs.writeFile(userCfgPath, initial, "utf-8");
customEnv = { PIP_CONFIG_FILE: userCfgPath };
const res = await runPip("pip3", ["install", "requests"]);
assert.strictEqual(res.status, 0);
const newCfgPath = capturedArgs.options.env.PIP_CONFIG_FILE;
assert.notStrictEqual(newCfgPath, userCfgPath, "should produce a new temp config file");
// Original remains unchanged
const originalParsed = ini.parse(await fs.readFile(userCfgPath, "utf-8"));
assert.strictEqual(originalParsed.global.cert, "/path/to/existing.pem", "original cert unchanged");
assert.strictEqual(originalParsed.global.proxy, undefined, "original proxy still missing");
// New file: cert and proxy always overwritten (read from captured content)
assert.ok(capturedConfigContent, "config content should have been captured");
const newParsed = ini.parse(capturedConfigContent);
assert.strictEqual(newParsed.global.cert, "/tmp/test-combined-ca.pem", "cert always overwritten in temp config");
assert.strictEqual(newParsed.global.proxy, "http://localhost:8080", "proxy always overwritten in temp config");
customEnv = null;
});
it("should log warnings when cert and proxy are already set in user config file", async () => {
const tmpDir = os.tmpdir();
const cfgPath = path.join(tmpDir, `safe-chain-test-pip-warn-${Date.now()}.ini`);
const initialIni = [
"[global]",
"cert = /user/cert.pem",
"proxy = http://user-proxy:9999",
""
].join("\n");
await fs.writeFile(cfgPath, initialIni, "utf-8");
customEnv = { PIP_CONFIG_FILE: cfgPath };
// Capture stdout/stderr
let output = "";
const originalWrite = process.stdout.write;
const originalError = process.stderr.write;
process.stdout.write = (chunk, ...args) => { output += chunk; return originalWrite.apply(process.stdout, [chunk, ...args]); };
process.stderr.write = (chunk, ...args) => { output += chunk; return originalError.apply(process.stderr, [chunk, ...args]); };
await runPip("pip3", ["install", "requests"]);
process.stdout.write = originalWrite;
process.stderr.write = originalError;
assert.ok(output.includes("cert found in PIP_CONFIG_FILE"), "Should warn about cert overwrite in output");
assert.ok(output.includes("proxy found in PIP_CONFIG_FILE"), "Should warn about proxy overwrite in output");
customEnv = null;
});
it("should bypass safe-chain for python correctly", async () => {
assert.strictEqual(shouldBypassSafeChain("python", []), true);
assert.strictEqual(shouldBypassSafeChain("python3", []), true);
assert.strictEqual(shouldBypassSafeChain("python", ["--version"]), true);
assert.strictEqual(shouldBypassSafeChain("python3", ["--version"]), true);
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "http.server"]), true);
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "http.server"]), true);
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip"]), false);
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip"]), false);
assert.strictEqual(shouldBypassSafeChain("python", ["-m", "pip3"]), false);
assert.strictEqual(shouldBypassSafeChain("python3", ["-m", "pip3"]), false);
});
});

View file

@ -0,0 +1,18 @@
import { runPipX } from "./runPipXCommand.js";
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPipXPackageManager() {
return {
/**
* @param {string[]} args
*/
runCommand: (args) => {
return runPipX("pipx", args);
},
// MITM only
isSupportedCommand: () => false,
getDependencyUpdatesForCommand: () => [],
};
}

View file

@ -0,0 +1,14 @@
import { test } from "node:test";
import assert from "node:assert";
import { createPipXPackageManager } from "./createPipXPackageManager.js";
test("createPipXPackageManager", async (t) => {
await t.test("should create package manager with required interface", () => {
const pm = createPipXPackageManager();
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,60 @@
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";
/**
* Sets CA bundle environment variables used by Python libraries and pipx.
*
* @param {NodeJS.ProcessEnv} env - Env object
* @param {string} combinedCaPath - Path to the combined CA bundle
* @return {NodeJS.ProcessEnv} Modified environment object
*/
function getPipXCaBundleEnvironmentVariables(env, combinedCaPath) {
let retVal = { ...env };
if (env.SSL_CERT_FILE) {
ui.writeWarning("Safe-chain: User defined SSL_CERT_FILE found in environment. It will be overwritten.");
}
retVal.SSL_CERT_FILE = combinedCaPath;
if (env.REQUESTS_CA_BUNDLE) {
ui.writeWarning("Safe-chain: User defined REQUESTS_CA_BUNDLE found in environment. It will be overwritten.");
}
retVal.REQUESTS_CA_BUNDLE = combinedCaPath;
if (env.PIP_CERT) {
ui.writeWarning("Safe-chain: User defined PIP_CERT found in environment. It will be overwritten.");
}
retVal.PIP_CERT = combinedCaPath;
return retVal;
}
/**
* Runs a pipx command with safe-chain's certificate bundle and proxy configuration.
*
* @param {string} command - The command to execute
* @param {string[]} args - Command line arguments
* @returns {Promise<{status: number}>} Exit status of the command
*/
export async function runPipX(command, args) {
try {
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
const combinedCaPath = getCombinedCaBundlePath();
const modifiedEnv = getPipXCaBundleEnvironmentVariables(env, combinedCaPath);
// Note: pipx uses HTTPS_PROXY and HTTP_PROXY environment variables for proxy configuration
// These are already set by mergeSafeChainProxyEnvironmentVariables
const result = await safeSpawn(command, args, {
stdio: "inherit",
env: modifiedEnv,
});
return { status: result.status };
} catch (/** @type any */ error) {
return reportCommandExecutionFailure(error, command);
}
}

View file

@ -0,0 +1,80 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert";
describe("runPipXCommand", () => {
let runPipX;
let safeSpawnMock;
let warnMock;
let errorMock;
let mergeCalls;
let mergedEnvReturn;
beforeEach(async () => {
mergeCalls = [];
mergedEnvReturn = {
HTTPS_PROXY: "http://localhost:8080",
HTTP_PROXY: "",
};
safeSpawnMock = mock.fn(async () => ({ status: 0 }));
warnMock = mock.fn();
errorMock = mock.fn();
mock.module("../../environment/userInteraction.js", {
namedExports: {
ui: {
writeWarning: warnMock,
writeError: errorMock,
writeInfo: () => {},
writeVerbose: () => {},
writeSuccess: () => {},
},
},
});
mock.module("../../registryProxy/registryProxy.js", {
namedExports: {
mergeSafeChainProxyEnvironmentVariables: (env) => {
mergeCalls.push(env);
return { ...env, ...mergedEnvReturn };
},
},
});
mock.module("../../registryProxy/certBundle.js", {
namedExports: {
getCombinedCaBundlePath: () => "/tmp/test-combined-ca.pem",
},
});
mock.module("../../utils/safeSpawn.js", {
namedExports: {
safeSpawn: safeSpawnMock,
},
});
const mod = await import("./runPipXCommand.js");
runPipX = mod.runPipX;
});
afterEach(() => {
mock.reset();
});
it("sets CA env vars and proxies before spawning", async () => {
const res = await runPipX("pipx", ["install", "ruff"]);
assert.strictEqual(res.status, 0);
assert.strictEqual(safeSpawnMock.mock.calls.length, 1, "safeSpawn should be called once");
const [, , options] = safeSpawnMock.mock.calls[0].arguments;
const env = options.env;
assert.strictEqual(env.SSL_CERT_FILE, "/tmp/test-combined-ca.pem");
assert.strictEqual(env.REQUESTS_CA_BUNDLE, "/tmp/test-combined-ca.pem");
assert.strictEqual(env.PIP_CERT, "/tmp/test-combined-ca.pem");
assert.strictEqual(env.HTTPS_PROXY, "http://localhost:8080");
assert.strictEqual(env.HTTP_PROXY, "");
assert.ok(mergeCalls.length >= 1, "proxy merge should be invoked");
});
});

View file

@ -4,6 +4,9 @@ import { runPnpmCommand } from "./runPnpmCommand.js";
const scanner = commandArgumentScanner();
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPnpmPackageManager() {
return {
runCommand: (args) => runPnpmCommand(args, "pnpm"),
@ -23,6 +26,9 @@ export function createPnpmPackageManager() {
};
}
/**
* @returns {import("../currentPackageManager.js").PackageManager}
*/
export function createPnpxPackageManager() {
return {
runCommand: (args) => runPnpmCommand(args, "pnpx"),
@ -32,6 +38,11 @@ export function createPnpxPackageManager() {
};
}
/**
* @param {string[]} args
* @param {boolean} isPnpx
* @returns {ReturnType<import("../currentPackageManager.js").PackageManager["getDependencyUpdatesForCommand"]>}
*/
function getDependencyUpdatesForCommand(args, isPnpx) {
if (isPnpx) {
return scanner.scan(args);

View file

@ -1,6 +1,9 @@
import { resolvePackageVersion } from "../../../api/npmApi.js";
import { parsePackagesFromArguments } from "../parsing/parsePackagesFromArguments.js";
/**
* @returns {import("../../npm/dependencyScanner/commandArgumentScanner.js").CommandArgumentScanner}
*/
export function commandArgumentScanner() {
return {
scan: (args) => scanDependencies(args),
@ -8,6 +11,10 @@ export function commandArgumentScanner() {
};
}
/**
* @param {string[]} args
* @returns {Promise<import("../../npm/dependencyScanner/commandArgumentScanner.js").ScanResult[]>}
*/
async function scanDependencies(args) {
const changes = [];
const packageUpdates = parsePackagesFromArguments(args);

View file

@ -1,3 +1,7 @@
/**
* @param {string[]} args
* @returns {{name: string, version: string}[]}
*/
export function parsePackagesFromArguments(args) {
const changes = [];
let defaultTag = "latest";
@ -22,6 +26,10 @@ export function parsePackagesFromArguments(args) {
return changes;
}
/**
* @param {string} arg
* @returns {{name: string, numberOfParameters: number} | undefined}
*/
function getOption(arg) {
if (isOptionWithParameter(arg)) {
return {
@ -42,12 +50,21 @@ function getOption(arg) {
return undefined;
}
/**
* @param {string} arg
* @returns {boolean}
*/
function isOptionWithParameter(arg) {
const optionsWithParameters = ["--C", "--dir"];
return optionsWithParameters.includes(arg);
}
/**
* @param {string} arg
* @param {string} defaultTag
* @returns {{name: string, version: string}}
*/
function parsePackagename(arg, defaultTag) {
// format can be --package=name@version
// in that case, we need to remove the --package= part
@ -77,6 +94,10 @@ function parsePackagename(arg, defaultTag) {
};
}
/**
* @param {string} arg
* @returns {string}
*/
function removeAlias(arg) {
// removes the alias.
// Eg.: server@npm:http-server@latest becomes http-server@latest

View file

@ -1,7 +1,12 @@
import { ui } from "../../environment/userInteraction.js";
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
import { safeSpawn } from "../../utils/safeSpawn.js";
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
/**
* @param {string[]} args
* @param {string} [toolName]
* @returns {Promise<{status: number}>}
*/
export async function runPnpmCommand(args, toolName = "pnpm") {
try {
let result;
@ -20,12 +25,8 @@ export async function runPnpmCommand(args, toolName = "pnpm") {
}
return { status: result.status };
} catch (error) {
if (error.status) {
return { status: error.status };
} else {
ui.writeError("Error executing command:", error.message);
return { status: 1 };
}
} catch (/** @type any */ error) {
const target = toolName === "pnpm" ? "pnpm" : "pnpx";
return reportCommandExecutionFailure(error, target);
}
}

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