mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Compare commits
167 commits
0.0.1-npm-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9453c8c0c9 | ||
|
|
2621f6f974 | ||
|
|
fd01d9f31b | ||
|
|
0414a79982 | ||
|
|
70b5e4d012 | ||
|
|
aed0aebdae | ||
|
|
6aec1bc474 | ||
|
|
f6145d5c20 | ||
|
|
ab058367f1 | ||
|
|
f2cce7b7e9 | ||
|
|
0b46c5408b | ||
|
|
07b8571758 | ||
|
|
3f0837c65a | ||
|
|
47e9ed0f6c | ||
|
|
cbbbe703d3 | ||
|
|
9d44eca1d1 | ||
|
|
b38aba43dd | ||
|
|
93c264ef84 | ||
|
|
34898980d7 | ||
|
|
a5c29d9e49 | ||
|
|
bf2d37d114 | ||
|
|
65a8075b0e | ||
|
|
a1b89a55f8 | ||
|
|
8ab5cebd4f | ||
|
|
ffe7f8de1f | ||
|
|
54db058ac7 | ||
|
|
8453012f7b | ||
|
|
e0e06431d1 | ||
|
|
6cdad3df98 | ||
|
|
d9b7aefd34 | ||
|
|
0c8de1e606 | ||
|
|
fde0003a0a | ||
|
|
c93f1920fb | ||
|
|
d812231b2f | ||
|
|
e5cd9eed91 | ||
|
|
25d966bfa9 | ||
|
|
5f0ad7ecfd | ||
|
|
6667e5d7b4 | ||
|
|
e891d1a992 | ||
|
|
26f1dfb81a | ||
|
|
7ce44b4c62 | ||
|
|
28132ba3fc | ||
|
|
55f2123f5c | ||
|
|
5f56114185 | ||
|
|
08ae1ef732 | ||
|
|
2eb32d4297 | ||
|
|
fbe094802e | ||
|
|
bd876275b3 | ||
|
|
cd5040c3be | ||
|
|
7b39239b81 | ||
|
|
369a94948a | ||
|
|
98a1ba7d10 | ||
|
|
5cf2ffe201 | ||
|
|
cb8db6c7a2 | ||
|
|
f4aa444cd8 | ||
|
|
da419a7785 | ||
|
|
00be33aa10 | ||
|
|
a0f0372e15 | ||
|
|
19d2dee5c9 | ||
|
|
cbf830a637 | ||
|
|
c8e25f3c21 | ||
|
|
fe161ba8a4 | ||
|
|
8571fc6996 | ||
|
|
f3fd003303 | ||
|
|
d0fc643f23 | ||
|
|
bf2bf24343 | ||
|
|
ebebe6d6c1 | ||
|
|
222216e22a | ||
|
|
4ef69d337f | ||
|
|
6abad2d37f | ||
|
|
ae40140199 | ||
|
|
725f7c399d | ||
|
|
dcd926f9d9 | ||
|
|
d04db58a5e | ||
|
|
9b42755502 | ||
|
|
e8fb134136 | ||
|
|
fbb856940f | ||
|
|
0a230eb64c | ||
|
|
dab616163f | ||
|
|
d81b0f5214 | ||
|
|
84346fdea7 | ||
|
|
c68fb2c7ed | ||
|
|
c22f36113c | ||
|
|
abbe0480b6 | ||
|
|
fff1422b51 | ||
|
|
88c969aee0 | ||
|
|
f56edf292b | ||
|
|
fbabd4e3c6 | ||
|
|
8dc5389ac9 | ||
|
|
a840a99f1b | ||
|
|
21b44eb4a8 | ||
|
|
b8d16c15b9 | ||
|
|
9fae225277 | ||
|
|
2930894624 | ||
|
|
3e71398430 | ||
|
|
464847a6fc | ||
|
|
33c3bec43d | ||
|
|
782af8e789 | ||
|
|
b3372cc50e | ||
|
|
7ed943d46f | ||
|
|
a68cf97f89 | ||
|
|
bafa997a70 | ||
|
|
cdb87792df | ||
|
|
6ff2ee3367 | ||
|
|
43fe715b08 | ||
|
|
0a9ab05468 | ||
|
|
8e4f036ce9 | ||
|
|
14c8abffea | ||
|
|
63b7a5ee5e | ||
|
|
f3ae77f12a | ||
|
|
7dd68cea12 | ||
|
|
50623cfc9a | ||
|
|
e54869ddd0 | ||
|
|
1076d6bea8 | ||
|
|
8dbeab8dac | ||
|
|
38a8130f4a | ||
|
|
f7324ccfc0 | ||
|
|
60732c5b6a | ||
|
|
dec9e82ee9 | ||
|
|
56a54b8683 | ||
|
|
32408c6583 | ||
|
|
f2bdd28ae6 | ||
|
|
5bbf3da576 | ||
|
|
f07d0ea888 | ||
|
|
72dc7dcf3a | ||
|
|
031c9683b1 | ||
|
|
d064d46668 | ||
|
|
1cf8fd1241 | ||
|
|
83f9f378f6 | ||
|
|
50f23d27fd | ||
|
|
e3077ebd6f | ||
|
|
9d5503aa54 | ||
|
|
2ea5362b07 | ||
|
|
df8be031cb | ||
|
|
98dcda78da | ||
|
|
ccd595fc22 | ||
|
|
94f77e1330 | ||
|
|
e5c79e5bd6 | ||
|
|
8cf41dc4a6 | ||
|
|
d7400a0bc0 | ||
|
|
eb9d0bba3e | ||
|
|
6628e1d4fd | ||
|
|
32c95dbb9d | ||
|
|
1aef941d1c | ||
|
|
b0f392522b | ||
|
|
24af6f21eb | ||
|
|
1635bee387 | ||
|
|
422963b38a | ||
|
|
a0fb8d6b3d | ||
|
|
698a12082d | ||
|
|
a6960d81e3 | ||
|
|
178b8a4423 | ||
|
|
738b1062b7 | ||
|
|
b116bc7016 | ||
|
|
f1307c6d82 | ||
|
|
2c568bb2a2 | ||
|
|
6db9f346e3 | ||
|
|
070afb9364 | ||
|
|
42102eb067 | ||
|
|
ced5e26420 | ||
|
|
f26cdab1f6 | ||
|
|
ca418de803 | ||
|
|
47ee9718d3 | ||
|
|
a5541df5ec | ||
|
|
1eb4fe05fd | ||
|
|
6f976f6a2b | ||
|
|
5690e55d99 |
88 changed files with 3521 additions and 1560 deletions
47
.github/workflows/build-and-release.yml
vendored
47
.github/workflows/build-and-release.yml
vendored
|
|
@ -60,12 +60,43 @@ jobs:
|
||||||
mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe
|
mv binaries/safe-chain-win-x64/safe-chain.exe release-artifacts/safe-chain-win-x64.exe
|
||||||
mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe
|
mv binaries/safe-chain-win-arm64/safe-chain.exe release-artifacts/safe-chain-win-arm64.exe
|
||||||
|
|
||||||
- name: Move install scripts and hard-code version
|
- name: Move install scripts and hard-code version and checksums
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.set-version.outputs.version }}
|
VERSION: ${{ needs.set-version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
sed "s/\$(fetch_latest_version)/${VERSION}/" install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
|
SHA_MACOS_X64=$(sha256sum release-artifacts/safe-chain-macos-x64 | awk '{print $1}')
|
||||||
sed "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1
|
SHA_MACOS_ARM64=$(sha256sum release-artifacts/safe-chain-macos-arm64 | awk '{print $1}')
|
||||||
|
SHA_LINUX_X64=$(sha256sum release-artifacts/safe-chain-linux-x64 | awk '{print $1}')
|
||||||
|
SHA_LINUX_ARM64=$(sha256sum release-artifacts/safe-chain-linux-arm64 | awk '{print $1}')
|
||||||
|
SHA_LINUXSTATIC_X64=$(sha256sum release-artifacts/safe-chain-linuxstatic-x64 | awk '{print $1}')
|
||||||
|
SHA_LINUXSTATIC_ARM64=$(sha256sum release-artifacts/safe-chain-linuxstatic-arm64 | awk '{print $1}')
|
||||||
|
SHA_WIN_X64=$(sha256sum release-artifacts/safe-chain-win-x64.exe | awk '{print $1}')
|
||||||
|
SHA_WIN_ARM64=$(sha256sum release-artifacts/safe-chain-win-arm64.exe | awk '{print $1}')
|
||||||
|
|
||||||
|
sed \
|
||||||
|
-e "s/\$(fetch_latest_version)/${VERSION}/" \
|
||||||
|
-e "s|^SHA256_MACOS_X64=\"\"|SHA256_MACOS_X64=\"${SHA_MACOS_X64}\"|" \
|
||||||
|
-e "s|^SHA256_MACOS_ARM64=\"\"|SHA256_MACOS_ARM64=\"${SHA_MACOS_ARM64}\"|" \
|
||||||
|
-e "s|^SHA256_LINUX_X64=\"\"|SHA256_LINUX_X64=\"${SHA_LINUX_X64}\"|" \
|
||||||
|
-e "s|^SHA256_LINUX_ARM64=\"\"|SHA256_LINUX_ARM64=\"${SHA_LINUX_ARM64}\"|" \
|
||||||
|
-e "s|^SHA256_LINUXSTATIC_X64=\"\"|SHA256_LINUXSTATIC_X64=\"${SHA_LINUXSTATIC_X64}\"|" \
|
||||||
|
-e "s|^SHA256_LINUXSTATIC_ARM64=\"\"|SHA256_LINUXSTATIC_ARM64=\"${SHA_LINUXSTATIC_ARM64}\"|" \
|
||||||
|
-e "s|^SHA256_WIN_X64=\"\"|SHA256_WIN_X64=\"${SHA_WIN_X64}\"|" \
|
||||||
|
-e "s|^SHA256_WIN_ARM64=\"\"|SHA256_WIN_ARM64=\"${SHA_WIN_ARM64}\"|" \
|
||||||
|
install-scripts/install-safe-chain.sh > release-artifacts/install-safe-chain.sh
|
||||||
|
|
||||||
|
sed \
|
||||||
|
-e "s/\$Version = Get-LatestVersion/\$Version = \"${VERSION}\"/" \
|
||||||
|
-e "s|^\$SHA256_MACOS_X64 = \"\"|\$SHA256_MACOS_X64 = \"${SHA_MACOS_X64}\"|" \
|
||||||
|
-e "s|^\$SHA256_MACOS_ARM64 = \"\"|\$SHA256_MACOS_ARM64 = \"${SHA_MACOS_ARM64}\"|" \
|
||||||
|
-e "s|^\$SHA256_LINUX_X64 = \"\"|\$SHA256_LINUX_X64 = \"${SHA_LINUX_X64}\"|" \
|
||||||
|
-e "s|^\$SHA256_LINUX_ARM64 = \"\"|\$SHA256_LINUX_ARM64 = \"${SHA_LINUX_ARM64}\"|" \
|
||||||
|
-e "s|^\$SHA256_LINUXSTATIC_X64 = \"\"|\$SHA256_LINUXSTATIC_X64 = \"${SHA_LINUXSTATIC_X64}\"|" \
|
||||||
|
-e "s|^\$SHA256_LINUXSTATIC_ARM64 = \"\"|\$SHA256_LINUXSTATIC_ARM64 = \"${SHA_LINUXSTATIC_ARM64}\"|" \
|
||||||
|
-e "s|^\$SHA256_WIN_X64 = \"\"|\$SHA256_WIN_X64 = \"${SHA_WIN_X64}\"|" \
|
||||||
|
-e "s|^\$SHA256_WIN_ARM64 = \"\"|\$SHA256_WIN_ARM64 = \"${SHA_WIN_ARM64}\"|" \
|
||||||
|
install-scripts/install-safe-chain.ps1 > release-artifacts/install-safe-chain.ps1
|
||||||
|
|
||||||
cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh
|
cp install-scripts/uninstall-safe-chain.sh release-artifacts/uninstall-safe-chain.sh
|
||||||
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1
|
cp install-scripts/uninstall-safe-chain.ps1 release-artifacts/uninstall-safe-chain.ps1
|
||||||
cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh
|
cp install-scripts/install-endpoint-mac.sh release-artifacts/install-endpoint-mac.sh
|
||||||
|
|
@ -113,8 +144,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: "lts/*"
|
node-version: "lts/*"
|
||||||
registry-url: "https://registry.npmjs.org/"
|
registry-url: "https://registry.npmjs.org/"
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
|
||||||
|
|
||||||
- name: Setup safe-chain
|
- name: Setup safe-chain
|
||||||
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
run: curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||||
|
|
@ -137,5 +166,11 @@ jobs:
|
||||||
|
|
||||||
- name: Publish to npm
|
- name: Publish to npm
|
||||||
run: |
|
run: |
|
||||||
echo "Publishing version ${{ github.event.release.tag_name }} to NPM"
|
VERSION="${{ github.event.release.tag_name }}"
|
||||||
|
echo "Publishing version $VERSION to NPM"
|
||||||
|
if [[ "$VERSION" == *"-"* ]]; then
|
||||||
|
PRERELEASE_TAG=$(echo "$VERSION" | sed 's/.*-\([^-]*\)$/\1/')
|
||||||
|
npm publish --workspace=packages/safe-chain --access public --provenance --tag "$PRERELEASE_TAG"
|
||||||
|
else
|
||||||
npm publish --workspace=packages/safe-chain --access public --provenance
|
npm publish --workspace=packages/safe-chain --access public --provenance
|
||||||
|
fi
|
||||||
|
|
|
||||||
82
.github/workflows/bump-endpoint.yml
vendored
Normal file
82
.github/workflows/bump-endpoint.yml
vendored
Normal 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}\"}"
|
||||||
9
.github/workflows/test-on-pr.yml
vendored
9
.github/workflows/test-on-pr.yml
vendored
|
|
@ -77,7 +77,7 @@ jobs:
|
||||||
- node_version: "20"
|
- node_version: "20"
|
||||||
npm_version: "9.0.0"
|
npm_version: "9.0.0"
|
||||||
yarn_version: "latest"
|
yarn_version: "latest"
|
||||||
pnpm_version: "latest"
|
pnpm_version: "10.0.0"
|
||||||
# Version pinning scenario
|
# Version pinning scenario
|
||||||
- node_version: "22"
|
- node_version: "22"
|
||||||
npm_version: "10.2.0"
|
npm_version: "10.2.0"
|
||||||
|
|
@ -87,17 +87,12 @@ jobs:
|
||||||
- node_version: "18"
|
- node_version: "18"
|
||||||
npm_version: "latest"
|
npm_version: "latest"
|
||||||
yarn_version: "latest"
|
yarn_version: "latest"
|
||||||
pnpm_version: "latest"
|
pnpm_version: "10.0.0"
|
||||||
# Future compatibility (becomes LTS October 2025)
|
# Future compatibility (becomes LTS October 2025)
|
||||||
- node_version: "24"
|
- node_version: "24"
|
||||||
npm_version: "latest"
|
npm_version: "latest"
|
||||||
yarn_version: "latest"
|
yarn_version: "latest"
|
||||||
pnpm_version: "latest"
|
pnpm_version: "latest"
|
||||||
# EOL compatibility testing - Node 16 (EOL Sept 2023)
|
|
||||||
- node_version: "16"
|
|
||||||
npm_version: "8.0.0"
|
|
||||||
yarn_version: "1.22.0"
|
|
||||||
pnpm_version: "8.0.0"
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|
|
||||||
63
README.md
63
README.md
|
|
@ -10,6 +10,14 @@
|
||||||
- ✅ **Blocks packages newer than 48 hours** without breaking your build
|
- ✅ **Blocks packages newer than 48 hours** without breaking your build
|
||||||
- ✅ **Tokenless, free, no build data shared**
|
- ✅ **Tokenless, free, no build data shared**
|
||||||
|
|
||||||
|
## Need protection beyond npm & PyPI?
|
||||||
|
|
||||||
|
[Aikido Endpoint](https://www.aikido.dev/protect/endpoint-protection?utm_source=github.com&utm_medium=referral&utm_campaign=safechain) builds on Safe Chain, extending package and extension security across more ecosystems: **npm**, **PyPI**, **Maven**, **NuGet**, **VS Code**, **Open VSX** - (Cursor, Windsurf, Kiro, Vs Codium, ...), **Chrome extensions**, **Skills.sh AI skills** and more.
|
||||||
|
|
||||||
|
Get centralized policy management, request-and-approval workflows, and visibility across every developer workstation in your org. Powered by the same Aikido Intel feed. Deploy it manually or manage it through your MDM tool (Jamf, Fleet, or Iru).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Aikido Safe Chain supports the following package managers:
|
Aikido Safe Chain supports the following package managers:
|
||||||
|
|
||||||
- 📦 **npm**
|
- 📦 **npm**
|
||||||
|
|
@ -17,13 +25,17 @@ Aikido Safe Chain supports the following package managers:
|
||||||
- 📦 **yarn**
|
- 📦 **yarn**
|
||||||
- 📦 **pnpm**
|
- 📦 **pnpm**
|
||||||
- 📦 **pnpx**
|
- 📦 **pnpx**
|
||||||
|
- 📦 **rush**
|
||||||
|
- 📦 **rushx**
|
||||||
- 📦 **bun**
|
- 📦 **bun**
|
||||||
- 📦 **bunx**
|
- 📦 **bunx**
|
||||||
- 📦 **pip**
|
- 📦 **pip**
|
||||||
- 📦 **pip3**
|
- 📦 **pip3**
|
||||||
- 📦 **uv**
|
- 📦 **uv**
|
||||||
- 📦 **poetry**
|
- 📦 **poetry**
|
||||||
|
- 📦 **uvx**
|
||||||
- 📦 **pipx**
|
- 📦 **pipx**
|
||||||
|
- 📦 **pdm**
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
|
|
@ -66,7 +78,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
|
||||||
### Verify the installation
|
### Verify the installation
|
||||||
|
|
||||||
1. **❗Restart your terminal** to start using the Aikido Safe Chain.
|
1. **❗Restart your terminal** to start using the Aikido Safe Chain.
|
||||||
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, poetry, uv and pipx are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
- This step is crucial as it ensures that the shell aliases for npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, poetry, uv, uvx, pipx and pdm are loaded correctly. If you do not restart your terminal, the aliases will not be available.
|
||||||
|
|
||||||
2. **Verify the installation** by running the verification command:
|
2. **Verify the installation** by running the verification command:
|
||||||
|
|
||||||
|
|
@ -97,7 +109,7 @@ You can find all available versions on the [releases page](https://github.com/Ai
|
||||||
|
|
||||||
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
|
- The output should show that Aikido Safe Chain is blocking the installation of these test packages as they are flagged as malware.
|
||||||
|
|
||||||
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
When running `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx` and `pdm` commands, the Aikido Safe Chain will automatically check for malware in the packages you are trying to install. It also intercepts Python module invocations for pip when available (e.g., `python -m pip install ...`, `python3 -m pip download ...`). If any malware is detected, it will prompt you to exit the command.
|
||||||
|
|
||||||
You can check the installed version by running:
|
You can check the installed version by running:
|
||||||
|
|
||||||
|
|
@ -109,7 +121,7 @@ safe-chain --version
|
||||||
|
|
||||||
### Malware Blocking
|
### Malware Blocking
|
||||||
|
|
||||||
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, bun, bunx, pip, pip3, uv, poetry or pipx commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
|
The Aikido Safe Chain works by running a lightweight proxy server that intercepts package downloads from the npm registry and PyPI. When you run npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, pip, pip3, uv, uvx, poetry, pipx or pdm commands, all package downloads are routed through this local proxy, which verifies packages in real-time against **[Aikido Intel - Open Sources Threat Intelligence](https://intel.aikido.dev/?tab=malware)**. If malware is detected in any package (including deep dependencies), the proxy blocks the download before the malicious code reaches your machine.
|
||||||
|
|
||||||
### Minimum package age
|
### Minimum package age
|
||||||
|
|
||||||
|
|
@ -128,7 +140,7 @@ By default, the minimum package age is 48 hours. This provides an additional sec
|
||||||
|
|
||||||
### Shell Integration
|
### Shell Integration
|
||||||
|
|
||||||
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, bun, bunx, and Python package managers (pip, uv, poetry, pipx). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
|
The Aikido Safe Chain integrates with your shell to provide a seamless experience when using npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, and Python package managers (pip, uv, uvx, poetry, pipx, pdm). It sets up aliases for these commands so that they are wrapped by the Aikido Safe Chain commands, which manage the proxy server before executing the original commands. We currently support:
|
||||||
|
|
||||||
- ✅ **Bash**
|
- ✅ **Bash**
|
||||||
- ✅ **Zsh**
|
- ✅ **Zsh**
|
||||||
|
|
@ -281,6 +293,12 @@ You can set custom registries through environment variable or config file. Both
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## PYPI Configuration File
|
||||||
|
|
||||||
|
If you rely on a `pip.conf` file for pip configuration you must point pip at it explicitly via the `PIP_CONFIG_FILE` environment variable so Safe Chain can merge it.
|
||||||
|
|
||||||
|
Safe Chain runs pip behind its MITM proxy and writes a temporary pip configuration file to inject its certificate and proxy settings. When `PIP_CONFIG_FILE` is set, Safe Chain merges its settings into a copy of your file (your original file is never modified) so your `index-url`, credentials, and other options are preserved. When `PIP_CONFIG_FILE` is not set, pip's user-level config (e.g. `~/.config/pip/pip.conf`) might be overridden by Safe Chain's temporary file and your settings will not be picked up.
|
||||||
|
|
||||||
## Malware List Base URL
|
## 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.
|
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.
|
||||||
|
|
@ -316,6 +334,24 @@ The base URL should point to a server that mirrors the structure of `https://mal
|
||||||
- `/releases/npm.json` (JavaScript new packages list)
|
- `/releases/npm.json` (JavaScript new packages list)
|
||||||
- `/releases/pypi.json` (Python new packages list)
|
- `/releases/pypi.json` (Python new packages list)
|
||||||
|
|
||||||
|
## Custom Install Directory
|
||||||
|
|
||||||
|
By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by passing an explicit install directory to the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools.
|
||||||
|
|
||||||
|
When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`.
|
||||||
|
|
||||||
|
### Unix/Linux/macOS
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --install-dir /usr/local/.safe-chain
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -InstallDir 'C:\ProgramData\safe-chain'"
|
||||||
|
```
|
||||||
|
|
||||||
# Usage in CI/CD
|
# Usage in CI/CD
|
||||||
|
|
||||||
You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation.
|
You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation.
|
||||||
|
|
@ -406,6 +442,7 @@ pipeline {
|
||||||
environment {
|
environment {
|
||||||
// Jenkins does not automatically persist PATH updates from setup-ci,
|
// Jenkins does not automatically persist PATH updates from setup-ci,
|
||||||
// so add the shims + binary directory explicitly for all stages.
|
// so add the shims + binary directory explicitly for all stages.
|
||||||
|
// If you installed into a custom directory, replace ~/.safe-chain with that path here.
|
||||||
PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}"
|
PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,7 +480,7 @@ steps:
|
||||||
name: Install
|
name: Install
|
||||||
script:
|
script:
|
||||||
- curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
- curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||||
- export PATH=~/.safe-chain/shims:$PATH
|
- export PATH=~/.safe-chain/shims:~/.safe-chain/bin:$PATH
|
||||||
- npm ci
|
- npm ci
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -461,7 +498,7 @@ To add safe-chain in GitLab pipelines, you need to install it in the image runni
|
||||||
# Install safe-chain
|
# Install safe-chain
|
||||||
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci
|
||||||
|
|
||||||
# Add safe-chain to PATH
|
# Add safe-chain to PATH (update paths if you used a custom install dir)
|
||||||
ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}"
|
ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -514,4 +551,16 @@ npm-ci:
|
||||||
|
|
||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
Having issues? See the [Troubleshooting Guide](https://help.aikido.dev/code-scanning/aikido-malware-scanning/safe-chain-troubleshooting) for help with common problems.
|
Having issues? See the [Troubleshooting Guide](./docs/troubleshooting) for help with common problems.
|
||||||
|
|
||||||
|
# Report Issues
|
||||||
|
|
||||||
|
If you encounter problems:
|
||||||
|
|
||||||
|
1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues)
|
||||||
|
2. Include:
|
||||||
|
* Operating system and version
|
||||||
|
* Shell type and version
|
||||||
|
* `safe-chain --version` output
|
||||||
|
* Output from verification commands
|
||||||
|
* Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument)
|
||||||
|
|
|
||||||
25
docs/Release.md
Normal file
25
docs/Release.md
Normal 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.
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
|
The shell integration automatically wraps common package manager commands (`npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry`, `pipx`) with Aikido's security scanning functionality. It also intercepts Python module invocations for pip when available: `python -m pip`, `python -m pip3`, `python3 -m pip`, `python3 -m pip3`. This is achieved by sourcing startup scripts that define shell functions to wrap these commands with their Aikido-protected equivalents.
|
||||||
|
|
||||||
## Supported Shells
|
## Supported Shells
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ This command:
|
||||||
|
|
||||||
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
|
- Copies necessary startup scripts to Safe Chain's installation directory (`~/.safe-chain/scripts`)
|
||||||
- Detects all supported shells on your system
|
- Detects all supported shells on your system
|
||||||
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx`
|
- Sources each shell's startup file to add Safe Chain functions for `npm`, `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx`
|
||||||
- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name
|
- Adds lightweight interceptors so `python -m pip[...]` and `python3 -m pip[...]` route through Safe Chain when invoked by name
|
||||||
|
|
||||||
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
|
❗ After running this command, **you must restart your terminal** for the changes to take effect. This ensures that the startup scripts are sourced correctly.
|
||||||
|
|
@ -78,7 +78,7 @@ The system modifies the following files to source Safe Chain startup scripts:
|
||||||
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
|
This means the shell functions are working but the Aikido commands aren't installed or available in your PATH:
|
||||||
|
|
||||||
- Make sure Aikido Safe Chain is properly installed on your system
|
- Make sure Aikido Safe Chain is properly installed on your system
|
||||||
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-poetry` and `aikido-pipx` commands exist
|
- Verify the `aikido-npm`, `aikido-npx`, `aikido-yarn`, `aikido-pnpm`, `aikido-pnpx`, `aikido-rush`, `aikido-rushx`, `aikido-bun`, `aikido-bunx`, `aikido-pip`, `aikido-pip3`, `aikido-uv`, `aikido-uvx`, `aikido-poetry` and `aikido-pipx` commands exist
|
||||||
- Check that these commands are in your system's PATH
|
- Check that these commands are in your system's PATH
|
||||||
|
|
||||||
### Manual Verification
|
### Manual Verification
|
||||||
|
|
@ -121,7 +121,7 @@ npm() {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
Repeat this pattern for `npx`, `yarn`, `pnpm`, `pnpx`, `rush`, `rushx`, `bun`, `bunx`, `pip`, `pip3`, `uv`, `uvx`, `poetry` and `pipx` using their respective `aikido-*` commands. After adding these functions, restart your terminal to apply the changes.
|
||||||
|
|
||||||
To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions:
|
To intercept Python module invocations for pip without altering Python itself, you can add small forwarding functions:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,49 +4,38 @@ This guide helps you diagnose and resolve common issues with Aikido Safe Chain.
|
||||||
|
|
||||||
## Verification & Diagnostics
|
## Verification & Diagnostics
|
||||||
|
|
||||||
### Check Installation
|
**Check Installation**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check version
|
# Check version
|
||||||
safe-chain --version
|
safe-chain --version
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify Shell Integration
|
**Verify Shell Integration**
|
||||||
|
|
||||||
Run the verification command for your package manager:
|
Run the verification command for your package manager:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm safe-chain-verify
|
npm safe-chain-verify
|
||||||
pnpm safe-chain-verify
|
pnpm safe-chain-verify
|
||||||
pip safe-chain-verify
|
|
||||||
uv safe-chain-verify
|
|
||||||
|
|
||||||
# Any other supported package manager: {packagemanager} safe-chain-verify
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```
|
||||||
Expected output: `OK: Safe-chain works!`
|
Expected output: `OK: Safe-chain works!`
|
||||||
|
```
|
||||||
|
|
||||||
### Test Malware Blocking
|
**Test Malware Blocking**
|
||||||
|
|
||||||
Verify that malware detection is working:
|
Verify that malware detection is working:
|
||||||
|
|
||||||
**For JavaScript/Node.js:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install safe-chain-test
|
|
||||||
```
|
```
|
||||||
|
npm install safe-chain-test
|
||||||
**For Python:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip3 install safe-chain-pi-test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
These test packages are flagged as malware and should be blocked by Safe Chain.
|
These test packages are flagged as malware and should be blocked by Safe Chain.
|
||||||
|
|
||||||
**If the test package installs successfully instead of being blocked**, see [Malware Not Being Blocked](#malware-not-being-blocked) below.
|
**If the test package installs successfully instead of being blocked**, see Malware Not Being Blocked below.
|
||||||
|
|
||||||
### Logging Options
|
## Logging Options
|
||||||
|
|
||||||
Use logging flags or environment variables to get more information:
|
Use logging flags or environment variables to get more information:
|
||||||
|
|
||||||
|
|
@ -74,41 +63,39 @@ Safe-chain blocks malicious packages by intercepting network requests to package
|
||||||
|
|
||||||
When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy.
|
When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy.
|
||||||
|
|
||||||
**Resolution Steps:**
|
**Resolution Steps**
|
||||||
|
|
||||||
1. **Clear your package manager's cache:**
|
1) Clear your package manager's cache
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For npm
|
# For npm
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# For pnpm
|
# For pnpm
|
||||||
pnpm store prune
|
pnpm store prune
|
||||||
|
|
||||||
# For yarn (classic)
|
# For yarn (classic)
|
||||||
yarn cache clean
|
yarn cache clean
|
||||||
|
|
||||||
# For yarn (berry/v2+)
|
# For yarn (berry/v2+)
|
||||||
yarn cache clean --all
|
yarn cache clean --all
|
||||||
|
|
||||||
# For bun
|
# For bun
|
||||||
bun pm cache rm
|
bun pm cache rm
|
||||||
```
|
```
|
||||||
|
|
||||||
> **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times.
|
2) Clean local installation artifacts:
|
||||||
|
|
||||||
2. **Clean local installation artifacts:**
|
```bash
|
||||||
|
# Remove node_modules if you want a completely fresh install
|
||||||
|
rm -rf node_modules
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
3) Re-test malware blocking:
|
||||||
# Remove node_modules if you want a completely fresh install
|
|
||||||
rm -rf node_modules
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Re-test malware blocking:**
|
```bash
|
||||||
|
npm install safe-chain-test # Should be blocked
|
||||||
```bash
|
```
|
||||||
npm install safe-chain-test # Should be blocked
|
|
||||||
```
|
|
||||||
|
|
||||||
### Shell Aliases Not Working After Installation
|
### Shell Aliases Not Working After Installation
|
||||||
|
|
||||||
|
|
@ -128,10 +115,10 @@ Should show: `npm is a function`
|
||||||
|
|
||||||
Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`:
|
Check that your startup file sources safe-chain scripts from `~/.safe-chain/scripts/`:
|
||||||
|
|
||||||
- Bash: `~/.bashrc`
|
* Bash: `~/.bashrc`
|
||||||
- Zsh: `~/.zshrc`
|
* Zsh: `~/.zshrc`
|
||||||
- Fish: `~/.config/fish/config.fish`
|
* Fish: `~/.config/fish/config.fish`
|
||||||
- PowerShell: `$PROFILE`
|
* PowerShell: `$PROFILE`
|
||||||
|
|
||||||
### "Command Not Found: safe-chain"
|
### "Command Not Found: safe-chain"
|
||||||
|
|
||||||
|
|
@ -162,37 +149,39 @@ FullyQualifiedErrorId : UnauthorizedAccess
|
||||||
|
|
||||||
**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile.
|
**Cause:** Windows PowerShell's default execution policy (`Restricted`) blocks all script execution, including safe-chain's initialization script that's sourced from your PowerShell profile.
|
||||||
|
|
||||||
**Resolution:**
|
**Resolution**
|
||||||
|
|
||||||
1. **Set the execution policy to allow local scripts:**
|
1) Set the execution policy to allow local scripts
|
||||||
|
|
||||||
Open PowerShell as Administrator and run:
|
Open PowerShell as Administrator and run:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
|
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
|
||||||
```
|
```
|
||||||
|
|
||||||
This allows:
|
This allows:
|
||||||
- Local scripts (like safe-chain's) to run without signing
|
|
||||||
- Downloaded scripts to run only if signed by a trusted publisher
|
|
||||||
|
|
||||||
2. **Restart PowerShell** and verify the error is resolved.
|
* Local scripts (like safe-chain's) to run without signing
|
||||||
|
* Downloaded scripts to run only if signed by a trusted publisher
|
||||||
|
|
||||||
> **Note:** `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability.
|
2) Restart PowerShell and verify the error is resolved.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> `RemoteSigned` is Microsoft's recommended execution policy for client computers. It provides a good balance between security and usability.
|
||||||
|
|
||||||
### Shell Aliases Persist After Uninstallation
|
### Shell Aliases Persist After Uninstallation
|
||||||
|
|
||||||
**Symptom:** safe-chain commands still active after running uninstall script
|
**Symptom:** safe-chain commands still active after running uninstall script
|
||||||
|
|
||||||
**Steps:**
|
**Steps**
|
||||||
|
|
||||||
1. Run `safe-chain teardown` (if binary still exists)
|
1. Run `safe-chain teardown` (if binary still exists)
|
||||||
2. Restart your terminal
|
2. Restart your terminal
|
||||||
3. If still present, manually edit shell config files:
|
3. If still present, manually edit shell config files:
|
||||||
- Bash: `~/.bashrc`
|
* Bash: `~/.bashrc`
|
||||||
- Zsh: `~/.zshrc`
|
* Zsh: `~/.zshrc`
|
||||||
- Fish: `~/.config/fish/config.fish`
|
* Fish: `~/.config/fish/config.fish`
|
||||||
- PowerShell: `$PROFILE`
|
* PowerShell: `$PROFILE`
|
||||||
4. Remove lines that source scripts from `~/.safe-chain/scripts/`
|
4. Remove lines that source scripts from `~/.safe-chain/scripts/`
|
||||||
5. Restart terminal again
|
5. Restart terminal again
|
||||||
|
|
||||||
|
|
@ -217,10 +206,10 @@ type pip
|
||||||
|
|
||||||
**Expected `which` output:**
|
**Expected `which` output:**
|
||||||
|
|
||||||
- Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users/<username>/.safe-chain/bin/safe-chain`
|
* Standalone binary (correct): `~/.safe-chain/bin/safe-chain` or `/Users/<username>/.safe-chain/bin/safe-chain`
|
||||||
- npm global (outdated): path containing `node_modules` or nvm version paths
|
* npm global (outdated): path containing `node_modules` or nvm version paths
|
||||||
|
|
||||||
If `which` shows an npm installation, see [Check for Conflicting Installations](#check-for-conflicting-installations).
|
If `which` shows an npm installation, see Check for Conflicting Installations.
|
||||||
|
|
||||||
### Check Shell Integration
|
### Check Shell Integration
|
||||||
|
|
||||||
|
|
@ -259,23 +248,23 @@ for version in $(nvm list | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+'); do
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual Cleanup
|
### Manual Cleanup
|
||||||
|
|
||||||
> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails.
|
> **Note:** The install and uninstall scripts automatically handle these cleanup steps. Use these manual commands only if automatic cleanup fails.
|
||||||
|
|
||||||
### Remove npm Global Installation
|
#### Remove npm Global Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm uninstall -g @aikidosec/safe-chain
|
npm uninstall -g @aikidosec/safe-chain
|
||||||
```
|
```
|
||||||
|
|
||||||
### Remove Volta Installation
|
#### Remove Volta Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
volta uninstall @aikidosec/safe-chain
|
volta uninstall @aikidosec/safe-chain
|
||||||
```
|
```
|
||||||
|
|
||||||
### Remove nvm Installations (All Versions)
|
#### Remove nvm Installations (All Versions)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Automated approach
|
# Automated approach
|
||||||
|
|
@ -288,34 +277,22 @@ nvm use <version>
|
||||||
npm uninstall -g @aikidosec/safe-chain
|
npm uninstall -g @aikidosec/safe-chain
|
||||||
```
|
```
|
||||||
|
|
||||||
### Clean Shell Configuration Files
|
#### Clean Shell Configuration Files
|
||||||
|
|
||||||
Manually remove safe-chain entries from:
|
Manually remove safe-chain entries from:
|
||||||
|
|
||||||
- Bash: `~/.bashrc`
|
* Bash: `~/.bashrc`
|
||||||
- Zsh: `~/.zshrc`
|
* Zsh: `~/.zshrc`
|
||||||
- Fish: `~/.config/fish/config.fish`
|
* Fish: `~/.config/fish/config.fish`
|
||||||
- PowerShell: `$PROFILE`
|
* PowerShell: `$PROFILE`
|
||||||
|
|
||||||
Look for and remove:
|
Look for and remove:
|
||||||
|
|
||||||
- Lines sourcing from `~/.safe-chain/scripts/`
|
* Lines sourcing from `~/.safe-chain/scripts/`
|
||||||
- Any safe-chain related function definitions
|
* Any safe-chain related function definitions
|
||||||
|
|
||||||
### Remove Installation Directory
|
#### Remove Installation Directory
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf ~/.safe-chain
|
rm -rf ~/.safe-chain
|
||||||
```
|
```
|
||||||
|
|
||||||
### Report Issues
|
|
||||||
|
|
||||||
If you encounter problems:
|
|
||||||
|
|
||||||
1. Visit [GitHub Issues](https://github.com/AikidoSec/safe-chain/issues)
|
|
||||||
2. Include:
|
|
||||||
- Operating system and version
|
|
||||||
- Shell type and version
|
|
||||||
- `safe-chain --version` output
|
|
||||||
- Output from verification commands
|
|
||||||
- Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument)
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
set -e # Exit on error
|
set -e # Exit on error
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.12/EndpointProtection.pkg"
|
INSTALL_URL="https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.pkg"
|
||||||
DOWNLOAD_SHA256="26492f3cbb1094532dc298199842eb97d60cc670552c9c256314960b298ee784"
|
DOWNLOAD_SHA256="ad800f9e476b0a75bf32b1c079f060ecb98bc16972a4e8cca29cf165388ea9fe"
|
||||||
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
|
TOKEN_FILE="/tmp/aikido_endpoint_token.txt"
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ param(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.2.12/EndpointProtection.msi"
|
$InstallUrl = "https://github.com/AikidoSec/safechain-internals/releases/download/v1.5.4/EndpointProtection.msi"
|
||||||
$DownloadSha256 = "06308fc06f95f4b2ad9e48bfd978eb8d02c2928f2ee3c8bba2c81ef2fde21e4f"
|
$DownloadSha256 = "e2750c59124f53456a8f9cdb9e81fd9ce2f2491869f68f01602444ad519be5be"
|
||||||
|
|
||||||
# Ensure TLS 1.2 is enabled for downloads
|
# Ensure TLS 1.2 is enabled for downloads
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,68 @@
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[switch]$ci,
|
[switch]$ci,
|
||||||
[switch]$includepython
|
[switch]$includepython,
|
||||||
|
[string]$InstallDir
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validates and normalizes the requested install directory.
|
||||||
|
# Rejects non-absolute, root, PATH-like, and traversal-containing paths.
|
||||||
|
function Test-InstallDir {
|
||||||
|
param([string]$Dir)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Dir)) {
|
||||||
|
return @{ Ok = $true; Normalized = $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($Dir)) {
|
||||||
|
return @{ Ok = $false; Reason = "-InstallDir must be an absolute path, got: $Dir" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Dir.Contains([System.IO.Path]::PathSeparator)) {
|
||||||
|
return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$inputSegments = $Dir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||||
|
if ($inputSegments -contains "..") {
|
||||||
|
return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [System.IO.Path]::GetFullPath($Dir)
|
||||||
|
$root = [System.IO.Path]::GetPathRoot($normalized)
|
||||||
|
if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) {
|
||||||
|
return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{ Ok = $true; Normalized = $normalized }
|
||||||
|
}
|
||||||
|
|
||||||
$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set
|
$Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set
|
||||||
$InstallDir = Join-Path $env:USERPROFILE ".safe-chain\bin"
|
$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $HOME ".safe-chain" }
|
||||||
|
|
||||||
|
$installDirValidation = Test-InstallDir -Dir $SafeChainBase
|
||||||
|
if (-not $installDirValidation.Ok) {
|
||||||
|
Write-Host "[ERROR] $($installDirValidation.Reason)" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$SafeChainBase = $installDirValidation.Normalized
|
||||||
|
$InstallDir = Join-Path $SafeChainBase "bin"
|
||||||
$RepoUrl = "https://github.com/AikidoSec/safe-chain"
|
$RepoUrl = "https://github.com/AikidoSec/safe-chain"
|
||||||
|
|
||||||
|
# SHA256 checksums for release binaries.
|
||||||
|
# Empty in source; populated by the release pipeline.
|
||||||
|
# When empty (running from main), checksum verification is skipped.
|
||||||
|
# Non-Windows hashes are unused today (PS script is Windows-only) but baked in
|
||||||
|
# for future cross-platform support.
|
||||||
|
$SHA256_MACOS_X64 = ""
|
||||||
|
$SHA256_MACOS_ARM64 = ""
|
||||||
|
$SHA256_LINUX_X64 = ""
|
||||||
|
$SHA256_LINUX_ARM64 = ""
|
||||||
|
$SHA256_LINUXSTATIC_X64 = ""
|
||||||
|
$SHA256_LINUXSTATIC_ARM64 = ""
|
||||||
|
$SHA256_WIN_X64 = ""
|
||||||
|
$SHA256_WIN_ARM64 = ""
|
||||||
|
|
||||||
# Ensure TLS 1.2 is enabled for downloads
|
# Ensure TLS 1.2 is enabled for downloads
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
||||||
|
|
@ -98,6 +153,91 @@ function Get-Architecture {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Emits the deprecation warning for SAFE_CHAIN_VERSION and prints the version-pinned install command.
|
||||||
|
# Returns immediately when no version was provided through the environment.
|
||||||
|
function Write-VersionDeprecationWarning {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated."
|
||||||
|
Write-Warn ""
|
||||||
|
Write-Warn "Please use direct download URLs for version pinning instead:"
|
||||||
|
Write-Warn ""
|
||||||
|
if ($ci) {
|
||||||
|
Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`""
|
||||||
|
} else {
|
||||||
|
Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)"
|
||||||
|
}
|
||||||
|
Write-Warn ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Builds the Windows release binary filename for the detected architecture.
|
||||||
|
# Centralizes binary name generation for the download step.
|
||||||
|
function Get-BinaryName {
|
||||||
|
param([string]$Architecture)
|
||||||
|
|
||||||
|
return "safe-chain-win-$Architecture.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns the expected SHA256 for the given OS+arch, or empty if not baked in.
|
||||||
|
function Get-ExpectedSha256 {
|
||||||
|
param([string]$Os, [string]$Architecture)
|
||||||
|
switch ("$Os-$Architecture") {
|
||||||
|
"macos-x64" { return $SHA256_MACOS_X64 }
|
||||||
|
"macos-arm64" { return $SHA256_MACOS_ARM64 }
|
||||||
|
"linux-x64" { return $SHA256_LINUX_X64 }
|
||||||
|
"linux-arm64" { return $SHA256_LINUX_ARM64 }
|
||||||
|
"linuxstatic-x64" { return $SHA256_LINUXSTATIC_X64 }
|
||||||
|
"linuxstatic-arm64" { return $SHA256_LINUXSTATIC_ARM64 }
|
||||||
|
"win-x64" { return $SHA256_WIN_X64 }
|
||||||
|
"win-arm64" { return $SHA256_WIN_ARM64 }
|
||||||
|
default { return "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Checksum {
|
||||||
|
param([string]$File, [string]$Expected)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Expected)) { return }
|
||||||
|
|
||||||
|
$actual = (Get-FileHash -Path $File -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
$expectedLower = $Expected.ToLowerInvariant()
|
||||||
|
|
||||||
|
if ($actual -ne $expectedLower) {
|
||||||
|
Remove-Item -Path $File -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Error-Custom "Checksum verification failed. Expected: $expectedLower, Got: $actual"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Info "Checksum verified."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs safe-chain setup or setup-ci after the binary is installed.
|
||||||
|
# Temporarily appends the install directory to PATH and downgrades setup failures to warnings.
|
||||||
|
function Invoke-SafeChainSetup {
|
||||||
|
param(
|
||||||
|
[string]$BinaryPath,
|
||||||
|
[string]$InstallDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
$setupCmd = if ($ci) { "setup-ci" } else { "setup" }
|
||||||
|
|
||||||
|
Write-Info "Running safe-chain $setupCmd..."
|
||||||
|
try {
|
||||||
|
$env:Path = "$env:Path;$InstallDirectory"
|
||||||
|
& $BinaryPath $setupCmd
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warn "safe-chain was installed but setup encountered issues."
|
||||||
|
Write-Warn "You can run 'safe-chain $setupCmd' manually later."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warn "safe-chain was installed but setup encountered issues: $_"
|
||||||
|
Write-Warn "You can run 'safe-chain $setupCmd' manually later."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Check and uninstall npm global package if present
|
# Check and uninstall npm global package if present
|
||||||
function Remove-NpmInstallation {
|
function Remove-NpmInstallation {
|
||||||
# Check if npm is available
|
# Check if npm is available
|
||||||
|
|
@ -149,19 +289,7 @@ function Remove-VoltaInstallation {
|
||||||
|
|
||||||
# Main installation
|
# Main installation
|
||||||
function Install-SafeChain {
|
function Install-SafeChain {
|
||||||
# Show deprecation warning if SAFE_CHAIN_VERSION is set
|
Write-VersionDeprecationWarning
|
||||||
if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) {
|
|
||||||
Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated."
|
|
||||||
Write-Warn ""
|
|
||||||
Write-Warn "Please use direct download URLs for version pinning instead:"
|
|
||||||
Write-Warn ""
|
|
||||||
if ($ci) {
|
|
||||||
Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`""
|
|
||||||
} else {
|
|
||||||
Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)"
|
|
||||||
}
|
|
||||||
Write-Warn ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fetch latest version if VERSION is not set
|
# Fetch latest version if VERSION is not set
|
||||||
if ([string]::IsNullOrWhiteSpace($Version)) {
|
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||||
|
|
@ -192,7 +320,7 @@ function Install-SafeChain {
|
||||||
|
|
||||||
# Detect platform
|
# Detect platform
|
||||||
$arch = Get-Architecture
|
$arch = Get-Architecture
|
||||||
$binaryName = "safe-chain-win-$arch.exe"
|
$binaryName = Get-BinaryName -Architecture $arch
|
||||||
|
|
||||||
Write-Info "Detected architecture: $arch"
|
Write-Info "Detected architecture: $arch"
|
||||||
|
|
||||||
|
|
@ -223,6 +351,9 @@ function Install-SafeChain {
|
||||||
Write-Error-Custom "Failed to download from $downloadUrl : $_"
|
Write-Error-Custom "Failed to download from $downloadUrl : $_"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$expectedSha = Get-ExpectedSha256 -Os "win" -Architecture $arch
|
||||||
|
Test-Checksum -File $tempFile -Expected $expectedSha
|
||||||
|
|
||||||
# Rename to final location
|
# Rename to final location
|
||||||
$finalFile = Join-Path $InstallDir "safe-chain.exe"
|
$finalFile = Join-Path $InstallDir "safe-chain.exe"
|
||||||
try {
|
try {
|
||||||
|
|
@ -238,31 +369,7 @@ function Install-SafeChain {
|
||||||
|
|
||||||
Write-Info "Binary installed to: $finalFile"
|
Write-Info "Binary installed to: $finalFile"
|
||||||
|
|
||||||
# Build setup command based on parameters
|
Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir
|
||||||
$setupCmd = if ($ci) { "setup-ci" } else { "setup" }
|
|
||||||
$setupArgs = @()
|
|
||||||
|
|
||||||
# Execute safe-chain setup
|
|
||||||
Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..."
|
|
||||||
try {
|
|
||||||
$env:Path = "$env:Path;$InstallDir"
|
|
||||||
|
|
||||||
if ($setupArgs) {
|
|
||||||
& $finalFile $setupCmd $setupArgs
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
& $finalFile $setupCmd
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Warn "safe-chain was installed but setup encountered issues."
|
|
||||||
Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warn "safe-chain was installed but setup encountered issues: $_"
|
|
||||||
Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run installation
|
# Run installation
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,67 @@
|
||||||
|
|
||||||
set -e # Exit on error
|
set -e # Exit on error
|
||||||
|
|
||||||
|
# Validates a user-provided install dir and exits on unsafe values.
|
||||||
|
# Rejects relative paths, root paths, PATH separators, and traversal segments.
|
||||||
|
validate_install_dir() {
|
||||||
|
dir="$1"
|
||||||
|
|
||||||
|
if [ -z "$dir" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$dir" in
|
||||||
|
/*) ;;
|
||||||
|
*)
|
||||||
|
printf '[ERROR] --install-dir must be an absolute path, got: %s\n' "$dir" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$dir" in
|
||||||
|
*:*)
|
||||||
|
printf '[ERROR] --install-dir must not contain the PATH separator (:)\n' >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$dir" = "/" ]; then
|
||||||
|
printf '[ERROR] --install-dir cannot be a root or drive-root directory\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
old_ifs=$IFS
|
||||||
|
IFS='/'
|
||||||
|
set -- $dir
|
||||||
|
IFS=$old_ifs
|
||||||
|
|
||||||
|
for segment in "$@"; do
|
||||||
|
if [ "$segment" = ".." ]; then
|
||||||
|
printf '[ERROR] --install-dir must not contain path traversal segments\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set
|
VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set
|
||||||
INSTALL_DIR="${HOME}/.safe-chain/bin"
|
SAFE_CHAIN_BASE="${HOME}/.safe-chain"
|
||||||
|
|
||||||
|
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
|
||||||
REPO_URL="https://github.com/AikidoSec/safe-chain"
|
REPO_URL="https://github.com/AikidoSec/safe-chain"
|
||||||
|
|
||||||
|
# SHA256 checksums for release binaries.
|
||||||
|
# Empty in source; populated by the release pipeline via sed.
|
||||||
|
# When empty (running from main), checksum verification is skipped.
|
||||||
|
SHA256_MACOS_X64=""
|
||||||
|
SHA256_MACOS_ARM64=""
|
||||||
|
SHA256_LINUX_X64=""
|
||||||
|
SHA256_LINUX_ARM64=""
|
||||||
|
SHA256_LINUXSTATIC_X64=""
|
||||||
|
SHA256_LINUXSTATIC_ARM64=""
|
||||||
|
SHA256_WIN_X64=""
|
||||||
|
SHA256_WIN_ARM64=""
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
|
|
@ -112,6 +168,57 @@ fetch_latest_version() {
|
||||||
echo "$latest_version"
|
echo "$latest_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Returns the expected SHA256 for the detected platform, or empty if the
|
||||||
|
# release pipeline has not baked one in (i.e. running the source from main).
|
||||||
|
get_expected_sha256() {
|
||||||
|
os="$1"; arch="$2"
|
||||||
|
case "${os}-${arch}" in
|
||||||
|
macos-x64) echo "$SHA256_MACOS_X64" ;;
|
||||||
|
macos-arm64) echo "$SHA256_MACOS_ARM64" ;;
|
||||||
|
linux-x64) echo "$SHA256_LINUX_X64" ;;
|
||||||
|
linux-arm64) echo "$SHA256_LINUX_ARM64" ;;
|
||||||
|
linuxstatic-x64) echo "$SHA256_LINUXSTATIC_X64" ;;
|
||||||
|
linuxstatic-arm64) echo "$SHA256_LINUXSTATIC_ARM64" ;;
|
||||||
|
win-x64) echo "$SHA256_WIN_X64" ;;
|
||||||
|
win-arm64) echo "$SHA256_WIN_ARM64" ;;
|
||||||
|
*) echo "" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
compute_sha256() {
|
||||||
|
file="$1"
|
||||||
|
if command_exists sha256sum; then
|
||||||
|
sha256sum "$file" | awk '{print $1}'
|
||||||
|
elif command_exists shasum; then
|
||||||
|
shasum -a 256 "$file" | awk '{print $1}'
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verifies the downloaded binary against the expected hash baked in by the release pipeline.
|
||||||
|
# No-op when no expected hash is set (running the script from main).
|
||||||
|
verify_checksum() {
|
||||||
|
file="$1"; expected="$2"
|
||||||
|
|
||||||
|
if [ -z "$expected" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
actual=$(compute_sha256 "$file")
|
||||||
|
if [ -z "$actual" ]; then
|
||||||
|
rm -f "$file"
|
||||||
|
error "Cannot verify checksum: neither sha256sum nor shasum is available. Install one and re-run."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$actual" != "$expected" ]; then
|
||||||
|
rm -f "$file"
|
||||||
|
error "Checksum verification failed. Expected: $expected, Got: $actual"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Checksum verified."
|
||||||
|
}
|
||||||
|
|
||||||
# Download file
|
# Download file
|
||||||
download() {
|
download() {
|
||||||
url="$1"
|
url="$1"
|
||||||
|
|
@ -126,6 +233,75 @@ download() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Prints the deprecation warning for SAFE_CHAIN_VERSION and the replacement install command.
|
||||||
|
# Returns immediately when no version was pinned through the environment.
|
||||||
|
warn_deprecated_version_env() {
|
||||||
|
if [ -z "$SAFE_CHAIN_VERSION" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "SAFE_CHAIN_VERSION environment variable is deprecated."
|
||||||
|
warn ""
|
||||||
|
warn "Please use direct download URLs for version pinning instead:"
|
||||||
|
warn ""
|
||||||
|
if [ "$USE_CI_SETUP" = "true" ]; then
|
||||||
|
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci"
|
||||||
|
else
|
||||||
|
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh"
|
||||||
|
fi
|
||||||
|
warn ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensures VERSION is populated before installation continues.
|
||||||
|
# Fetches the latest release only when no explicit version was provided.
|
||||||
|
ensure_version() {
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Fetching latest release version..."
|
||||||
|
VERSION=$(fetch_latest_version)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Constructs platform-specific binary filename to match GitHub release asset naming convention.
|
||||||
|
get_binary_name() {
|
||||||
|
os="$1"
|
||||||
|
arch="$2"
|
||||||
|
|
||||||
|
if [ "$os" = "win" ]; then
|
||||||
|
printf 'safe-chain-%s-%s.exe\n' "$os" "$arch"
|
||||||
|
else
|
||||||
|
printf 'safe-chain-%s-%s\n' "$os" "$arch"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns the final installation path for the downloaded safe-chain binary.
|
||||||
|
# Uses INSTALL_DIR and the platform-specific executable name.
|
||||||
|
get_final_binary_path() {
|
||||||
|
os="$1"
|
||||||
|
|
||||||
|
if [ "$os" = "win" ]; then
|
||||||
|
printf '%s/safe-chain.exe\n' "$INSTALL_DIR"
|
||||||
|
else
|
||||||
|
printf '%s/safe-chain\n' "$INSTALL_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_setup_command() {
|
||||||
|
final_file="$1"
|
||||||
|
|
||||||
|
setup_cmd="setup"
|
||||||
|
if [ "$USE_CI_SETUP" = "true" ]; then
|
||||||
|
setup_cmd="setup-ci"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Running safe-chain $setup_cmd..."
|
||||||
|
if ! "$final_file" "$setup_cmd"; then
|
||||||
|
warn "safe-chain was installed but setup encountered issues."
|
||||||
|
warn "You can run 'safe-chain $setup_cmd' manually later."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Check and uninstall npm global package if present
|
# Check and uninstall npm global package if present
|
||||||
remove_npm_installation() {
|
remove_npm_installation() {
|
||||||
if ! command_exists npm; then
|
if ! command_exists npm; then
|
||||||
|
|
@ -229,19 +405,39 @@ remove_nvm_installation() {
|
||||||
|
|
||||||
# Parse command-line arguments
|
# Parse command-line arguments
|
||||||
parse_arguments() {
|
parse_arguments() {
|
||||||
for arg in "$@"; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$arg" in
|
case "$1" in
|
||||||
--ci)
|
--ci)
|
||||||
USE_CI_SETUP=true
|
USE_CI_SETUP=true
|
||||||
;;
|
;;
|
||||||
|
--install-dir)
|
||||||
|
shift
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
error "Missing value for --install-dir"
|
||||||
|
fi
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
error "--install-dir must not be empty"
|
||||||
|
fi
|
||||||
|
SAFE_CHAIN_BASE="$1"
|
||||||
|
;;
|
||||||
|
--install-dir=*)
|
||||||
|
SAFE_CHAIN_BASE="${1#--install-dir=}"
|
||||||
|
if [ -z "$SAFE_CHAIN_BASE" ]; then
|
||||||
|
error "--install-dir must not be empty"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
--include-python)
|
--include-python)
|
||||||
warn "--include-python is deprecated and ignored. Python ecosystem is now included by default."
|
warn "--include-python is deprecated and ignored. Python ecosystem is now included by default."
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
error "Unknown argument: $arg"
|
error "Unknown argument: $1"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
|
validate_install_dir "${SAFE_CHAIN_BASE}"
|
||||||
|
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main installation
|
# Main installation
|
||||||
|
|
@ -252,25 +448,9 @@ main() {
|
||||||
# Parse command-line arguments
|
# Parse command-line arguments
|
||||||
parse_arguments "$@"
|
parse_arguments "$@"
|
||||||
|
|
||||||
# Show deprecation warning if SAFE_CHAIN_VERSION is set
|
warn_deprecated_version_env
|
||||||
if [ -n "$SAFE_CHAIN_VERSION" ]; then
|
|
||||||
warn "SAFE_CHAIN_VERSION environment variable is deprecated."
|
|
||||||
warn ""
|
|
||||||
warn "Please use direct download URLs for version pinning instead:"
|
|
||||||
warn ""
|
|
||||||
if [ "$USE_CI_SETUP" = "true" ]; then
|
|
||||||
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci"
|
|
||||||
else
|
|
||||||
warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh"
|
|
||||||
fi
|
|
||||||
warn ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Fetch latest version if VERSION is not set
|
ensure_version
|
||||||
if [ -z "$VERSION" ]; then
|
|
||||||
info "Fetching latest release version..."
|
|
||||||
VERSION=$(fetch_latest_version)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if the requested version is already installed
|
# Check if the requested version is already installed
|
||||||
if is_version_installed "$VERSION"; then
|
if is_version_installed "$VERSION"; then
|
||||||
|
|
@ -294,11 +474,7 @@ main() {
|
||||||
# Detect platform
|
# Detect platform
|
||||||
OS=$(detect_os)
|
OS=$(detect_os)
|
||||||
ARCH=$(detect_arch)
|
ARCH=$(detect_arch)
|
||||||
if [ "$OS" = "win" ]; then
|
BINARY_NAME=$(get_binary_name "$OS" "$ARCH")
|
||||||
BINARY_NAME="safe-chain-${OS}-${ARCH}.exe"
|
|
||||||
else
|
|
||||||
BINARY_NAME="safe-chain-${OS}-${ARCH}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Detected platform: ${OS}-${ARCH}"
|
info "Detected platform: ${OS}-${ARCH}"
|
||||||
|
|
||||||
|
|
@ -315,12 +491,11 @@ main() {
|
||||||
info "Downloading from: $DOWNLOAD_URL"
|
info "Downloading from: $DOWNLOAD_URL"
|
||||||
download "$DOWNLOAD_URL" "$TEMP_FILE"
|
download "$DOWNLOAD_URL" "$TEMP_FILE"
|
||||||
|
|
||||||
|
EXPECTED_SHA256=$(get_expected_sha256 "$OS" "$ARCH")
|
||||||
|
verify_checksum "$TEMP_FILE" "$EXPECTED_SHA256"
|
||||||
|
|
||||||
# Rename and make executable
|
# Rename and make executable
|
||||||
if [ "$OS" = "win" ]; then
|
FINAL_FILE=$(get_final_binary_path "$OS")
|
||||||
FINAL_FILE="${INSTALL_DIR}/safe-chain.exe"
|
|
||||||
else
|
|
||||||
FINAL_FILE="${INSTALL_DIR}/safe-chain"
|
|
||||||
fi
|
|
||||||
mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE"
|
mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE"
|
||||||
if [ "$OS" != "win" ]; then
|
if [ "$OS" != "win" ]; then
|
||||||
chmod +x "$FINAL_FILE" || error "Failed to make binary executable"
|
chmod +x "$FINAL_FILE" || error "Failed to make binary executable"
|
||||||
|
|
@ -328,20 +503,7 @@ main() {
|
||||||
|
|
||||||
info "Binary installed to: $FINAL_FILE"
|
info "Binary installed to: $FINAL_FILE"
|
||||||
|
|
||||||
# Build setup command based on arguments
|
run_setup_command "$FINAL_FILE"
|
||||||
SETUP_CMD="setup"
|
|
||||||
SETUP_ARGS=""
|
|
||||||
|
|
||||||
if [ "$USE_CI_SETUP" = "true" ]; then
|
|
||||||
SETUP_CMD="setup-ci"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Execute safe-chain setup
|
|
||||||
info "Running safe-chain $SETUP_CMD $SETUP_ARGS..."
|
|
||||||
if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then
|
|
||||||
warn "safe-chain was installed but setup encountered issues."
|
|
||||||
warn "You can run 'safe-chain $SETUP_CMD $SETUP_ARGS' manually later."
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
set -e # Exit on error
|
set -e # Exit on error
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
UNINSTALL_SCRIPT="/Library/Application Support/AikidoSecurity/EndpointProtection/scripts/uninstall"
|
UNINSTALL_SCRIPT="/Applications/Aikido Endpoint Protection.app/Contents/Resources/scripts/uninstall"
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@
|
||||||
|
|
||||||
# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform)
|
# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform)
|
||||||
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
|
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
|
||||||
$DotSafeChain = Join-Path $HomeDir ".safe-chain"
|
|
||||||
$InstallDir = Join-Path $DotSafeChain "bin"
|
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
function Write-Info {
|
function Write-Info {
|
||||||
|
|
@ -24,6 +22,146 @@ function Write-Error-Custom {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Derives the safe-chain base install directory from a resolved binary path.
|
||||||
|
# Rejects wrapper scripts and paths that do not match the packaged bin layout.
|
||||||
|
function Get-InstallDirFromBinaryPath {
|
||||||
|
param([string]$BinaryPath)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($BinaryPath)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resolvedPath = (Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop).Path
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$resolvedPath = [System.IO.Path]::GetFullPath($BinaryPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = [System.IO.Path]::GetFileName($resolvedPath)
|
||||||
|
if (($fileName -ne "safe-chain") -and ($fileName -ne "safe-chain.exe")) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolvedPath -match '\.(js|cjs|mjs|cmd|ps1)$') {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$binDir = Split-Path -Parent $resolvedPath
|
||||||
|
if ((Split-Path -Leaf $binDir) -ne "bin") {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Split-Path -Parent $binDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns the first safe-chain command found on PATH, if any.
|
||||||
|
# Used as the starting point for install-dir discovery.
|
||||||
|
function Get-SafeChainCommand {
|
||||||
|
return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns the safe-chain command path only when it points to a valid packaged binary install.
|
||||||
|
# Prevents teardown from invoking arbitrary wrappers or scripts from PATH.
|
||||||
|
function Get-ValidatedSafeChainCommandPath {
|
||||||
|
$command = Get-SafeChainCommand
|
||||||
|
if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$installDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path
|
||||||
|
if (-not $installDir) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return $command.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
# Invokes the validated safe-chain binary with get-install-dir and returns the reported base directory.
|
||||||
|
# Safely returns $null when the command is unavailable or the lookup fails.
|
||||||
|
function Get-ReportedInstallDir {
|
||||||
|
$safeChainPath = Get-ValidatedSafeChainCommandPath
|
||||||
|
if (-not $safeChainPath) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reportedInstallDir = & $safeChainPath get-install-dir 2>$null | Select-Object -First 1
|
||||||
|
if ($reportedInstallDir) {
|
||||||
|
$reportedInstallDir = $reportedInstallDir.Trim()
|
||||||
|
}
|
||||||
|
if ($reportedInstallDir) {
|
||||||
|
return $reportedInstallDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determines the safe-chain base install directory for uninstall.
|
||||||
|
# Prefers the binary-reported location, then derives it from PATH, then falls back to the default home-dir layout.
|
||||||
|
function Get-SafeChainInstallDir {
|
||||||
|
$reportedInstallDir = Get-ReportedInstallDir
|
||||||
|
if ($reportedInstallDir) {
|
||||||
|
return $reportedInstallDir
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = Get-SafeChainCommand
|
||||||
|
if ($command -and $command.Path) {
|
||||||
|
$discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path
|
||||||
|
if ($discoveredInstallDir) {
|
||||||
|
return $discoveredInstallDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Join-Path $HomeDir ".safe-chain")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Finds the installed safe-chain binary inside the resolved install directory.
|
||||||
|
# Falls back to a validated safe-chain command when the expected file is missing.
|
||||||
|
function Find-SafeChainBinary {
|
||||||
|
param([string]$DotSafeChain)
|
||||||
|
|
||||||
|
$safeChainExe = Join-Path $DotSafeChain "bin/safe-chain.exe"
|
||||||
|
$safeChainBin = Join-Path $DotSafeChain "bin/safe-chain"
|
||||||
|
|
||||||
|
if (Test-Path $safeChainExe) {
|
||||||
|
return $safeChainExe
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $safeChainBin) {
|
||||||
|
return $safeChainBin
|
||||||
|
}
|
||||||
|
|
||||||
|
return Get-ValidatedSafeChainCommandPath
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs safe-chain teardown before removing the installation directory.
|
||||||
|
# Converts teardown failures into warnings so uninstall can still complete.
|
||||||
|
function Invoke-SafeChainTeardown {
|
||||||
|
param([string]$SafeChainPath)
|
||||||
|
|
||||||
|
if (-not $SafeChainPath) {
|
||||||
|
Write-Warn "safe-chain command not found. Proceeding with uninstallation."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Info "Running safe-chain teardown..."
|
||||||
|
try {
|
||||||
|
& $SafeChainPath teardown
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warn "safe-chain teardown encountered issues: $_"
|
||||||
|
Write-Warn "Continuing with uninstallation..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Check and uninstall npm global package if present
|
# Check and uninstall npm global package if present
|
||||||
function Remove-NpmInstallation {
|
function Remove-NpmInstallation {
|
||||||
# Check if npm is available
|
# Check if npm is available
|
||||||
|
|
@ -76,49 +214,9 @@ function Remove-VoltaInstallation {
|
||||||
# Main uninstallation
|
# Main uninstallation
|
||||||
function Uninstall-SafeChain {
|
function Uninstall-SafeChain {
|
||||||
Write-Info "Uninstalling safe-chain..."
|
Write-Info "Uninstalling safe-chain..."
|
||||||
|
$DotSafeChain = Get-SafeChainInstallDir
|
||||||
# Run teardown if safe-chain is available
|
$safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain
|
||||||
# Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms
|
Invoke-SafeChainTeardown -SafeChainPath $safeChainPath
|
||||||
$safeChainExe = Join-Path $InstallDir "safe-chain.exe"
|
|
||||||
$safeChainBin = Join-Path $InstallDir "safe-chain"
|
|
||||||
|
|
||||||
$safeChainPath = $null
|
|
||||||
if (Test-Path $safeChainExe) {
|
|
||||||
$safeChainPath = $safeChainExe
|
|
||||||
}
|
|
||||||
elseif (Test-Path $safeChainBin) {
|
|
||||||
$safeChainPath = $safeChainBin
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($safeChainPath) {
|
|
||||||
Write-Info "Running safe-chain teardown..."
|
|
||||||
try {
|
|
||||||
& $safeChainPath teardown
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warn "safe-chain teardown encountered issues: $_"
|
|
||||||
Write-Warn "Continuing with uninstallation..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
elseif (Get-Command safe-chain -ErrorAction SilentlyContinue) {
|
|
||||||
Write-Info "Running safe-chain teardown..."
|
|
||||||
try {
|
|
||||||
safe-chain teardown
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warn "safe-chain teardown encountered issues: $_"
|
|
||||||
Write-Warn "Continuing with uninstallation..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Warn "safe-chain command not found. Proceeding with uninstallation."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove npm and Volta installations
|
# Remove npm and Volta installations
|
||||||
Remove-NpmInstallation
|
Remove-NpmInstallation
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
set -e # Exit on error
|
set -e # Exit on error
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
DOT_SAFE_CHAIN="${HOME}/.safe-chain"
|
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
|
|
@ -34,6 +33,159 @@ command_exists() {
|
||||||
command -v "$1" >/dev/null 2>&1
|
command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Resolves a path to its canonical filesystem location when possible.
|
||||||
|
# Follows symlinks so binary validation can inspect the real installed path.
|
||||||
|
resolve_path() {
|
||||||
|
target="$1"
|
||||||
|
|
||||||
|
while [ -L "$target" ]; do
|
||||||
|
link_target=$(readlink "$target" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$link_target" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$link_target" in
|
||||||
|
/*) target="$link_target" ;;
|
||||||
|
*)
|
||||||
|
target="$(dirname "$target")/$link_target"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
target_dir=$(dirname "$target")
|
||||||
|
target_name=$(basename "$target")
|
||||||
|
|
||||||
|
if cd "$target_dir" 2>/dev/null; then
|
||||||
|
printf '%s/%s\n' "$(pwd -P)" "$target_name"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$target"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Derives the safe-chain base install directory from a packaged binary path.
|
||||||
|
# Rejects wrapper scripts and paths that do not match the expected bin layout.
|
||||||
|
derive_install_dir_from_binary() {
|
||||||
|
binary_path="$1"
|
||||||
|
|
||||||
|
if [ -z "$binary_path" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
resolved_path=$(resolve_path "$binary_path")
|
||||||
|
binary_name=$(basename "$resolved_path")
|
||||||
|
case "$binary_name" in
|
||||||
|
safe-chain|safe-chain.exe) ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$resolved_path" in
|
||||||
|
*.js|*.cjs|*.mjs|*.cmd|*.ps1) return 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
binary_dir=$(dirname "$resolved_path")
|
||||||
|
if [ "$(basename "$binary_dir")" != "bin" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
dirname "$binary_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determines the installed safe-chain base directory for uninstall.
|
||||||
|
# Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain.
|
||||||
|
get_install_dir() {
|
||||||
|
reported_install_dir=$(get_reported_install_dir || true)
|
||||||
|
if [ -n "$reported_install_dir" ]; then
|
||||||
|
printf '%s\n' "$reported_install_dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command_path=$(get_safe_chain_command_path || true)
|
||||||
|
install_dir=$(derive_install_dir_from_binary "$command_path" || true)
|
||||||
|
if [ -n "$install_dir" ]; then
|
||||||
|
printf '%s\n' "$install_dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "${HOME}/.safe-chain"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns the current safe-chain command path from PATH.
|
||||||
|
# Fails when safe-chain is not currently resolvable.
|
||||||
|
get_safe_chain_command_path() {
|
||||||
|
if ! command_exists safe-chain; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v safe-chain
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns the safe-chain command path only when it resolves to a valid packaged binary install.
|
||||||
|
# Prevents the uninstaller from invoking arbitrary PATH entries.
|
||||||
|
get_validated_safe_chain_command_path() {
|
||||||
|
command_path=$(get_safe_chain_command_path || true)
|
||||||
|
if [ -z "$command_path" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_dir=$(derive_install_dir_from_binary "$command_path" || true)
|
||||||
|
if [ -z "$install_dir" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$command_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asks the validated safe-chain binary for its install directory via get-install-dir.
|
||||||
|
# Returns nothing if the command is unavailable or the lookup fails.
|
||||||
|
get_reported_install_dir() {
|
||||||
|
safe_chain_path=$(get_validated_safe_chain_command_path || true)
|
||||||
|
if [ -z "$safe_chain_path" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_dir=$("$safe_chain_path" get-install-dir 2>/dev/null || true)
|
||||||
|
if [ -n "$install_dir" ]; then
|
||||||
|
printf '%s\n' "$install_dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Locates the installed safe-chain binary to use for teardown.
|
||||||
|
# Checks the discovered install dir first, then falls back to a validated PATH entry.
|
||||||
|
find_installed_safe_chain_binary() {
|
||||||
|
dot_safe_chain="$1"
|
||||||
|
|
||||||
|
safe_chain_location="$dot_safe_chain/bin/safe-chain"
|
||||||
|
if [ -x "$safe_chain_location" ]; then
|
||||||
|
printf '%s\n' "$safe_chain_location"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
command_path=$(get_validated_safe_chain_command_path || true)
|
||||||
|
if [ -n "$command_path" ]; then
|
||||||
|
printf '%s\n' "$command_path"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Runs safe-chain teardown before removing files.
|
||||||
|
# Continues with uninstall even if teardown is unavailable or fails.
|
||||||
|
run_safe_chain_teardown() {
|
||||||
|
safe_chain_command="$1"
|
||||||
|
|
||||||
|
if [ -z "$safe_chain_command" ]; then
|
||||||
|
warn "safe-chain command not found. Proceeding with uninstallation."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Running safe-chain teardown..."
|
||||||
|
"$safe_chain_command" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
||||||
|
}
|
||||||
|
|
||||||
# Check and uninstall npm global package if present
|
# Check and uninstall npm global package if present
|
||||||
remove_npm_installation() {
|
remove_npm_installation() {
|
||||||
if ! command_exists npm; then
|
if ! command_exists npm; then
|
||||||
|
|
@ -139,17 +291,9 @@ remove_nvm_installation() {
|
||||||
|
|
||||||
# Main uninstallation
|
# Main uninstallation
|
||||||
main() {
|
main() {
|
||||||
SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain"
|
DOT_SAFE_CHAIN=$(get_install_dir)
|
||||||
|
SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true)
|
||||||
if [ -x "$SAFE_CHAIN_LOCATION" ]; then
|
run_safe_chain_teardown "$SAFE_CHAIN_COMMAND"
|
||||||
info "Running safe-chain teardown..."
|
|
||||||
"$SAFE_CHAIN_LOCATION" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
|
||||||
elif command_exists safe-chain; then
|
|
||||||
info "Running safe-chain teardown..."
|
|
||||||
safe-chain teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..."
|
|
||||||
else
|
|
||||||
warn "safe-chain command not found. Proceeding with uninstallation."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for existing safe-chain installation through nvm, volta, or npm
|
# Check for existing safe-chain installation through nvm, volta, or npm
|
||||||
remove_npm_installation
|
remove_npm_installation
|
||||||
|
|
|
||||||
946
npm-shrinkwrap.json
generated
946
npm-shrinkwrap.json
generated
File diff suppressed because it is too large
Load diff
13
packages/safe-chain/bin/aikido-pdm.js
Executable file
13
packages/safe-chain/bin/aikido-pdm.js
Executable 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);
|
||||||
|
})();
|
||||||
14
packages/safe-chain/bin/aikido-rush.js
Executable file
14
packages/safe-chain/bin/aikido-rush.js
Executable 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);
|
||||||
|
})();
|
||||||
14
packages/safe-chain/bin/aikido-rushx.js
Executable file
14
packages/safe-chain/bin/aikido-rushx.js
Executable 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);
|
||||||
|
})();
|
||||||
16
packages/safe-chain/bin/aikido-uvx.js
Executable file
16
packages/safe-chain/bin/aikido-uvx.js
Executable 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);
|
||||||
|
})();
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Strip PKG_EXECPATH from the environment so any child process safe-chain
|
||||||
|
// spawns (npm, uv, pip, …) doesn't inherit it. If it leaks into a subsequent
|
||||||
|
// safe-chain invocation (e.g. via a shim) the yao-pkg bootstrap would treat
|
||||||
|
// argv[1] as a script path and fail with MODULE_NOT_FOUND.
|
||||||
|
delete process.env.PKG_EXECPATH;
|
||||||
|
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { ui } from "../src/environment/userInteraction.js";
|
import { ui } from "../src/environment/userInteraction.js";
|
||||||
import { setup } from "../src/shell-integration/setup.js";
|
import { setup } from "../src/shell-integration/setup.js";
|
||||||
|
|
@ -15,7 +21,8 @@ import { main } from "../src/main.js";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { knownAikidoTools } from "../src/shell-integration/helpers.js";
|
import { knownAikidoTools, getPackageManagerList } from "../src/shell-integration/helpers.js";
|
||||||
|
import { getInstalledSafeChainDir } from "../src/installLocation.js";
|
||||||
|
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
// This checks the current file's dirname in a way that's compatible with:
|
// This checks the current file's dirname in a way that's compatible with:
|
||||||
|
|
@ -67,6 +74,17 @@ if (tool) {
|
||||||
teardownDirectories();
|
teardownDirectories();
|
||||||
} else if (command === "setup-ci") {
|
} else if (command === "setup-ci") {
|
||||||
setupCi();
|
setupCi();
|
||||||
|
} else if (command === "get-install-dir") {
|
||||||
|
const installDir = getInstalledSafeChainDir();
|
||||||
|
if (!installDir) {
|
||||||
|
ui.writeError(
|
||||||
|
"Install directory is only available for packaged safe-chain binaries.",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.writeInformation(installDir);
|
||||||
|
process.exit(0);
|
||||||
} else if (command === "--version" || command === "-v" || command === "-v") {
|
} else if (command === "--version" || command === "-v" || command === "-v") {
|
||||||
(async () => {
|
(async () => {
|
||||||
ui.writeInformation(`Current safe-chain version: ${await getVersion()}`);
|
ui.writeInformation(`Current safe-chain version: ${await getVersion()}`);
|
||||||
|
|
@ -88,7 +106,7 @@ function writeHelp() {
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
|
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
|
||||||
"teardown",
|
"teardown",
|
||||||
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan(
|
)}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("get-install-dir")}, ${chalk.cyan("help")}, ${chalk.cyan(
|
||||||
"--version",
|
"--version",
|
||||||
)}`,
|
)}`,
|
||||||
);
|
);
|
||||||
|
|
@ -96,7 +114,7 @@ function writeHelp() {
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
`- ${chalk.cyan(
|
`- ${chalk.cyan(
|
||||||
"safe-chain setup",
|
"safe-chain setup",
|
||||||
)}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`,
|
)}: This will setup your shell to wrap safe-chain around ${getPackageManagerList()}.`,
|
||||||
);
|
);
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
`- ${chalk.cyan(
|
`- ${chalk.cyan(
|
||||||
|
|
@ -108,6 +126,11 @@ function writeHelp() {
|
||||||
"safe-chain setup-ci",
|
"safe-chain setup-ci",
|
||||||
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`,
|
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`,
|
||||||
);
|
);
|
||||||
|
ui.writeInformation(
|
||||||
|
`- ${chalk.cyan(
|
||||||
|
"safe-chain get-install-dir",
|
||||||
|
)}: Print the install directory for packaged safe-chain binaries.`,
|
||||||
|
);
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
|
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
|
||||||
"-v",
|
"-v",
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,19 @@
|
||||||
"aikido-yarn": "bin/aikido-yarn.js",
|
"aikido-yarn": "bin/aikido-yarn.js",
|
||||||
"aikido-pnpm": "bin/aikido-pnpm.js",
|
"aikido-pnpm": "bin/aikido-pnpm.js",
|
||||||
"aikido-pnpx": "bin/aikido-pnpx.js",
|
"aikido-pnpx": "bin/aikido-pnpx.js",
|
||||||
|
"aikido-rush": "bin/aikido-rush.js",
|
||||||
|
"aikido-rushx": "bin/aikido-rushx.js",
|
||||||
"aikido-bun": "bin/aikido-bun.js",
|
"aikido-bun": "bin/aikido-bun.js",
|
||||||
"aikido-bunx": "bin/aikido-bunx.js",
|
"aikido-bunx": "bin/aikido-bunx.js",
|
||||||
"aikido-uv": "bin/aikido-uv.js",
|
"aikido-uv": "bin/aikido-uv.js",
|
||||||
|
"aikido-uvx": "bin/aikido-uvx.js",
|
||||||
"aikido-pip": "bin/aikido-pip.js",
|
"aikido-pip": "bin/aikido-pip.js",
|
||||||
"aikido-pip3": "bin/aikido-pip3.js",
|
"aikido-pip3": "bin/aikido-pip3.js",
|
||||||
"aikido-python": "bin/aikido-python.js",
|
"aikido-python": "bin/aikido-python.js",
|
||||||
"aikido-python3": "bin/aikido-python3.js",
|
"aikido-python3": "bin/aikido-python3.js",
|
||||||
"aikido-poetry": "bin/aikido-poetry.js",
|
"aikido-poetry": "bin/aikido-poetry.js",
|
||||||
"aikido-pipx": "bin/aikido-pipx.js",
|
"aikido-pipx": "bin/aikido-pipx.js",
|
||||||
|
"aikido-pdm": "bin/aikido-pdm.js",
|
||||||
"safe-chain": "bin/safe-chain.js"
|
"safe-chain": "bin/safe-chain.js"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -36,9 +40,8 @@
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Aikido Security",
|
"author": "Aikido Security",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), and [pip](https://pip.pypa.io/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, bun, bunx, uv, or pip/pip3 from downloading or running the malware.",
|
"description": "The Aikido Safe Chain wraps around the [npm cli](https://github.com/npm/cli), [npx](https://github.com/npm/cli/blob/latest/docs/content/commands/npx.md), [yarn](https://yarnpkg.com/), [pnpm](https://pnpm.io/), [pnpx](https://pnpm.io/cli/dlx), [rush](https://rushjs.io/), [rushx](https://rushjs.io/pages/commands/rushx/), [bun](https://bun.sh/), [bunx](https://bun.sh/docs/cli/bunx), [uv](https://docs.astral.sh/uv/) (Python), [pip](https://pip.pypa.io/), and [pdm](https://pdm-project.org/) to provide extra checks before installing new packages. This tool will detect when a package contains malware and prompt you to exit, preventing npm, npx, yarn, pnpm, pnpx, rush, rushx, bun, bunx, uv, uvx, pip/pip3, or pdm from downloading or running the malware.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
|
||||||
"certifi": "14.5.15",
|
"certifi": "14.5.15",
|
||||||
"chalk": "5.4.1",
|
"chalk": "5.4.1",
|
||||||
"https-proxy-agent": "7.0.6",
|
"https-proxy-agent": "7.0.6",
|
||||||
|
|
@ -49,7 +52,6 @@
|
||||||
"semver": "7.7.2"
|
"semver": "7.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/archiver": "^7.0.0",
|
|
||||||
"@types/ini": "^4.1.1",
|
"@types/ini": "^4.1.1",
|
||||||
"@types/make-fetch-happen": "^10.0.4",
|
"@types/make-fetch-happen": "^10.0.4",
|
||||||
"@types/node": "^18.19.130",
|
"@types/node": "^18.19.130",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
import { getEcoSystem } from "./settings.js";
|
import { getEcoSystem } from "./settings.js";
|
||||||
|
import { getSafeChainBaseDir } from "./safeChainDir.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} SafeChainConfig
|
* @typedef {Object} SafeChainConfig
|
||||||
|
|
@ -304,8 +305,7 @@ function getConfigFilePath() {
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getSafeChainDirectory() {
|
export function getSafeChainDirectory() {
|
||||||
const homeDir = os.homedir();
|
const safeChainDir = getSafeChainBaseDir();
|
||||||
const safeChainDir = path.join(homeDir, ".safe-chain");
|
|
||||||
|
|
||||||
if (!fs.existsSync(safeChainDir)) {
|
if (!fs.existsSync(safeChainDir)) {
|
||||||
fs.mkdirSync(safeChainDir, { recursive: true });
|
fs.mkdirSync(safeChainDir, { recursive: true });
|
||||||
|
|
|
||||||
71
packages/safe-chain/src/config/safeChainDir.js
Normal file
71
packages/safe-chain/src/config/safeChainDir.js
Normal 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);
|
||||||
|
}
|
||||||
42
packages/safe-chain/src/installLocation.js
Normal file
42
packages/safe-chain/src/installLocation.js
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
51
packages/safe-chain/src/installLocation.spec.js
Normal file
51
packages/safe-chain/src/installLocation.spec.js
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,6 +13,10 @@ import { createPipPackageManager } from "./pip/createPackageManager.js";
|
||||||
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
|
import { createUvPackageManager } from "./uv/createUvPackageManager.js";
|
||||||
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
|
import { createPoetryPackageManager } from "./poetry/createPoetryPackageManager.js";
|
||||||
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
|
import { createPipXPackageManager } from "./pipx/createPipXPackageManager.js";
|
||||||
|
import { createPdmPackageManager } from "./pdm/createPdmPackageManager.js";
|
||||||
|
import { createRushPackageManager } from "./rush/createRushPackageManager.js";
|
||||||
|
import { createRushxPackageManager } from "./rushx/createRushxPackageManager.js";
|
||||||
|
import { createUvxPackageManager } from "./uvx/createUvxPackageManager.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {{packageManagerName: PackageManager | null}}
|
* @type {{packageManagerName: PackageManager | null}}
|
||||||
|
|
@ -60,10 +64,18 @@ export function initializePackageManager(packageManagerName, context) {
|
||||||
state.packageManagerName = createPipPackageManager(context);
|
state.packageManagerName = createPipPackageManager(context);
|
||||||
} else if (packageManagerName === "uv") {
|
} else if (packageManagerName === "uv") {
|
||||||
state.packageManagerName = createUvPackageManager();
|
state.packageManagerName = createUvPackageManager();
|
||||||
|
} else if (packageManagerName === "uvx") {
|
||||||
|
state.packageManagerName = createUvxPackageManager();
|
||||||
} else if (packageManagerName === "poetry") {
|
} else if (packageManagerName === "poetry") {
|
||||||
state.packageManagerName = createPoetryPackageManager();
|
state.packageManagerName = createPoetryPackageManager();
|
||||||
} else if (packageManagerName === "pipx") {
|
} else if (packageManagerName === "pipx") {
|
||||||
state.packageManagerName = createPipXPackageManager();
|
state.packageManagerName = createPipXPackageManager();
|
||||||
|
} else if (packageManagerName === "pdm") {
|
||||||
|
state.packageManagerName = createPdmPackageManager();
|
||||||
|
} else if (packageManagerName === "rush") {
|
||||||
|
state.packageManagerName = createRushPackageManager();
|
||||||
|
} else if (packageManagerName === "rushx") {
|
||||||
|
state.packageManagerName = createRushxPackageManager();
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Unsupported package manager: " + packageManagerName);
|
throw new Error("Unsupported package manager: " + packageManagerName);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { runRushCommand } from "./runRushCommand.js";
|
||||||
|
import { resolvePackageVersion } from "../../api/npmApi.js";
|
||||||
|
import { parsePackagesFromRushAddArgs } from "./parsing/parsePackagesFromRushAddArgs.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||||
|
*/
|
||||||
|
export function createRushPackageManager() {
|
||||||
|
return {
|
||||||
|
runCommand: (args) => runRushCommand("rush", args),
|
||||||
|
// We pre-scan rush add commands and rely on MITM for install/update flows.
|
||||||
|
isSupportedCommand: (args) => getRushCommand(args) === "add",
|
||||||
|
getDependencyUpdatesForCommand: scanRushAddCommand,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} args
|
||||||
|
* @returns {Promise<import("../currentPackageManager.js").GetDependencyUpdatesResult[]>}
|
||||||
|
*/
|
||||||
|
async function scanRushAddCommand(args) {
|
||||||
|
if (getRushCommand(args) !== "add") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedSpecs = parsePackagesFromRushAddArgs(args.slice(1));
|
||||||
|
|
||||||
|
const resolvedVersions = await Promise.all(
|
||||||
|
parsedSpecs.map(async (parsed) => {
|
||||||
|
const exactVersion = await resolvePackageVersion(parsed.name, parsed.version);
|
||||||
|
return {
|
||||||
|
parsed,
|
||||||
|
exactVersion,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const changes = [];
|
||||||
|
for (const resolved of resolvedVersions) {
|
||||||
|
if (!resolved.exactVersion) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.push({
|
||||||
|
name: resolved.parsed.name,
|
||||||
|
version: resolved.exactVersion,
|
||||||
|
type: "add",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} args
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
function getRushCommand(args) {
|
||||||
|
if (!args || args.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return args[0]?.toLowerCase();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { test, mock } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
test("createRushPackageManager", async (t) => {
|
||||||
|
mock.module("../../api/npmApi.js", {
|
||||||
|
namedExports: {
|
||||||
|
resolvePackageVersion: async (name, version) => {
|
||||||
|
if (name === "safe-chain-test") {
|
||||||
|
return "0.0.1-security";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "@scope/tool") {
|
||||||
|
return version || "2.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createRushPackageManager } = await import("./createRushPackageManager.js");
|
||||||
|
|
||||||
|
await t.test("should create package manager with required interface", () => {
|
||||||
|
const pm = createRushPackageManager();
|
||||||
|
|
||||||
|
assert.ok(pm);
|
||||||
|
assert.strictEqual(typeof pm.runCommand, "function");
|
||||||
|
assert.strictEqual(typeof pm.isSupportedCommand, "function");
|
||||||
|
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test("should scan rush add commands", () => {
|
||||||
|
const pm = createRushPackageManager();
|
||||||
|
|
||||||
|
assert.strictEqual(pm.isSupportedCommand(["add", "--package", "safe-chain-test"]), true);
|
||||||
|
assert.strictEqual(pm.isSupportedCommand(["install"]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test("should parse rush add package specs and resolve versions", async () => {
|
||||||
|
const pm = createRushPackageManager();
|
||||||
|
|
||||||
|
const changes = await pm.getDependencyUpdatesForCommand([
|
||||||
|
"add",
|
||||||
|
"--package",
|
||||||
|
"safe-chain-test",
|
||||||
|
"--package=@scope/tool@1.2.3",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(changes, [
|
||||||
|
{ name: "safe-chain-test", version: "0.0.1-security", type: "add" },
|
||||||
|
{ name: "@scope/tool", version: "1.2.3", type: "add" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test("should return no changes for non-add commands", async () => {
|
||||||
|
const pm = createRushPackageManager();
|
||||||
|
|
||||||
|
const changes = await pm.getDependencyUpdatesForCommand(["install"]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(changes, []);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
mock.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* @param {string[]} args
|
||||||
|
* @returns {{name: string, version: string | null}[]}
|
||||||
|
*/
|
||||||
|
export function parsePackagesFromRushAddArgs(args) {
|
||||||
|
const packageSpecs = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (!arg) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--package" || arg === "-p") {
|
||||||
|
const next = args[i + 1];
|
||||||
|
if (next && !next.startsWith("-")) {
|
||||||
|
packageSpecs.push(next);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith("--package=")) {
|
||||||
|
const value = arg.slice("--package=".length);
|
||||||
|
if (value) {
|
||||||
|
packageSpecs.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return packageSpecs
|
||||||
|
.map((spec) => parsePackageSpec(spec))
|
||||||
|
.filter((spec) => spec !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} spec
|
||||||
|
* @returns {{name: string, version: string | null} | null}
|
||||||
|
*/
|
||||||
|
function parsePackageSpec(spec) {
|
||||||
|
const value = removeAlias(spec.trim());
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastAtIndex = value.lastIndexOf("@");
|
||||||
|
if (lastAtIndex > 0) {
|
||||||
|
return {
|
||||||
|
name: value.slice(0, lastAtIndex),
|
||||||
|
version: value.slice(lastAtIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: value,
|
||||||
|
version: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} spec
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function removeAlias(spec) {
|
||||||
|
const aliasIndex = spec.indexOf("@npm:");
|
||||||
|
if (aliasIndex !== -1) {
|
||||||
|
return spec.slice(aliasIndex + 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { parsePackagesFromRushAddArgs } from "./parsePackagesFromRushAddArgs.js";
|
||||||
|
|
||||||
|
describe("parsePackagesFromRushAddArgs", () => {
|
||||||
|
it("returns an empty array when no packages are provided", () => {
|
||||||
|
const result = parsePackagesFromRushAddArgs([]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses packages from --package arguments", () => {
|
||||||
|
const result = parsePackagesFromRushAddArgs([
|
||||||
|
"--package",
|
||||||
|
"axios@1.9.0",
|
||||||
|
"--package",
|
||||||
|
"@scope/tool@2.0.0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, [
|
||||||
|
{ name: "axios", version: "1.9.0" },
|
||||||
|
{ name: "@scope/tool", version: "2.0.0" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses packages from -p arguments", () => {
|
||||||
|
const result = parsePackagesFromRushAddArgs(["-p", "axios"]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, [{ name: "axios", version: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses packages from --package=value arguments", () => {
|
||||||
|
const result = parsePackagesFromRushAddArgs(["--package=axios@^1.9.0"]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, [{ name: "axios", version: "^1.9.0" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores positional packages because rush add requires --package", () => {
|
||||||
|
const result = parsePackagesFromRushAddArgs(["axios", "--dev"]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses aliases", () => {
|
||||||
|
const result = parsePackagesFromRushAddArgs(["--package", "server@npm:axios@1.9.0"]);
|
||||||
|
|
||||||
|
assert.deepEqual(result, [{ name: "axios", version: "1.9.0" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {"rush" | "rushx"} executableName
|
||||||
|
* @param {string[]} args
|
||||||
|
* @returns {Promise<{status: number}>}
|
||||||
|
*/
|
||||||
|
export async function runRushCommand(executableName, args) {
|
||||||
|
try {
|
||||||
|
const result = await safeSpawn(executableName, args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: result.status };
|
||||||
|
} catch (/** @type any */ error) {
|
||||||
|
return reportCommandExecutionFailure(error, executableName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("runRushCommand", () => {
|
||||||
|
let runRushCommand;
|
||||||
|
let safeSpawnMock;
|
||||||
|
let mergeCalls;
|
||||||
|
let mergeResultEnv;
|
||||||
|
let nextSpawnStatus;
|
||||||
|
let nextSpawnError;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mergeCalls = [];
|
||||||
|
mergeResultEnv = null;
|
||||||
|
nextSpawnStatus = 0;
|
||||||
|
nextSpawnError = null;
|
||||||
|
safeSpawnMock = mock.fn(async () => {
|
||||||
|
if (nextSpawnError) {
|
||||||
|
const error = nextSpawnError;
|
||||||
|
nextSpawnError = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: nextSpawnStatus };
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../../utils/safeSpawn.js", {
|
||||||
|
namedExports: {
|
||||||
|
safeSpawn: safeSpawnMock,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../../registryProxy/registryProxy.js", {
|
||||||
|
namedExports: {
|
||||||
|
mergeSafeChainProxyEnvironmentVariables: (env) => {
|
||||||
|
mergeCalls.push(env);
|
||||||
|
if (mergeResultEnv) {
|
||||||
|
return mergeResultEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...env,
|
||||||
|
HTTPS_PROXY: "http://localhost:8080",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// commandErrors reports through ui on failures, so provide a no-op mock
|
||||||
|
mock.module("../../environment/userInteraction.js", {
|
||||||
|
namedExports: {
|
||||||
|
ui: {
|
||||||
|
writeError: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mod = await import("./runRushCommand.js");
|
||||||
|
runRushCommand = mod.runRushCommand;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawns rush with merged proxy env", async () => {
|
||||||
|
const res = await runRushCommand("rush", ["install"]);
|
||||||
|
|
||||||
|
assert.strictEqual(res.status, 0);
|
||||||
|
assert.strictEqual(safeSpawnMock.mock.calls.length, 1);
|
||||||
|
|
||||||
|
const [command, args, options] = safeSpawnMock.mock.calls[0].arguments;
|
||||||
|
assert.strictEqual(command, "rush");
|
||||||
|
assert.deepStrictEqual(args, ["install"]);
|
||||||
|
assert.strictEqual(options.stdio, "inherit");
|
||||||
|
assert.strictEqual(options.env.HTTPS_PROXY, "http://localhost:8080");
|
||||||
|
assert.ok(mergeCalls.length >= 1, "proxy env merge should be called");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns spawn result status", async () => {
|
||||||
|
nextSpawnStatus = 7;
|
||||||
|
|
||||||
|
const res = await runRushCommand("rush", ["update"]);
|
||||||
|
|
||||||
|
assert.strictEqual(res.status, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports failures with rush target", async () => {
|
||||||
|
nextSpawnError = Object.assign(new Error("spawn failed"), {
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await runRushCommand("rush", ["install"]);
|
||||||
|
|
||||||
|
assert.strictEqual(res.status, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate merged env object", async () => {
|
||||||
|
mergeResultEnv = {
|
||||||
|
HTTPS_PROXY: "http://localhost:8080",
|
||||||
|
};
|
||||||
|
|
||||||
|
await runRushCommand("rush", ["install"]);
|
||||||
|
|
||||||
|
assert.deepStrictEqual(mergeResultEnv, {
|
||||||
|
HTTPS_PROXY: "http://localhost:8080",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { runRushCommand } from "../rush/runRushCommand.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||||
|
*/
|
||||||
|
export function createRushxPackageManager() {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* @param {string[]} args
|
||||||
|
*/
|
||||||
|
runCommand: (args) => {
|
||||||
|
return runRushCommand("rushx", args);
|
||||||
|
},
|
||||||
|
// For rushx, rely solely on MITM.
|
||||||
|
isSupportedCommand: () => false,
|
||||||
|
getDependencyUpdatesForCommand: () => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { createRushxPackageManager } from "./createRushxPackageManager.js";
|
||||||
|
|
||||||
|
test("createRushxPackageManager returns valid package manager interface", () => {
|
||||||
|
const pm = createRushxPackageManager();
|
||||||
|
|
||||||
|
assert.ok(pm);
|
||||||
|
assert.strictEqual(typeof pm.runCommand, "function");
|
||||||
|
assert.strictEqual(typeof pm.isSupportedCommand, "function");
|
||||||
|
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
|
||||||
|
assert.strictEqual(pm.isSupportedCommand(), false);
|
||||||
|
assert.deepStrictEqual(pm.getDependencyUpdatesForCommand(), []);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { runUv } from "../uv/runUvCommand.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {import("../currentPackageManager.js").PackageManager}
|
||||||
|
*/
|
||||||
|
export function createUvxPackageManager() {
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* @param {string[]} args
|
||||||
|
*/
|
||||||
|
runCommand: (args) => {
|
||||||
|
return runUv("uvx", args);
|
||||||
|
},
|
||||||
|
// For uvx, rely solely on MITM
|
||||||
|
isSupportedCommand: () => false,
|
||||||
|
getDependencyUpdatesForCommand: () => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { createUvxPackageManager } from "./createUvxPackageManager.js";
|
||||||
|
|
||||||
|
test("createUvxPackageManager returns valid package manager interface", () => {
|
||||||
|
const pm = createUvxPackageManager();
|
||||||
|
|
||||||
|
assert.ok(pm);
|
||||||
|
assert.strictEqual(typeof pm.runCommand, "function");
|
||||||
|
assert.strictEqual(typeof pm.isSupportedCommand, "function");
|
||||||
|
assert.strictEqual(typeof pm.getDependencyUpdatesForCommand, "function");
|
||||||
|
assert.strictEqual(pm.isSupportedCommand(), false);
|
||||||
|
assert.deepStrictEqual(pm.getDependencyUpdatesForCommand(), []);
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import forge from "node-forge";
|
import forge from "node-forge";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import os from "os";
|
import { getCertsDir } from "../config/safeChainDir.js";
|
||||||
|
|
||||||
const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
|
|
||||||
const ca = loadCa();
|
const ca = loadCa();
|
||||||
|
|
||||||
const certCache = new Map();
|
const certCache = new Map();
|
||||||
|
|
@ -20,7 +19,7 @@ function createKeyIdentifier(publicKey) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCaCertPath() {
|
export function getCaCertPath() {
|
||||||
return path.join(certFolder, "ca-cert.pem");
|
return path.join(getCertsDir(), "ca-cert.pem");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -112,6 +111,7 @@ export function generateCertForHost(hostname) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCa() {
|
function loadCa() {
|
||||||
|
const certFolder = getCertsDir();
|
||||||
const keyPath = path.join(certFolder, "ca-key.pem");
|
const keyPath = path.join(certFolder, "ca-key.pem");
|
||||||
const certPath = path.join(certFolder, "ca-cert.pem");
|
const certPath = path.join(certFolder, "ca-cert.pem");
|
||||||
|
|
||||||
|
|
|
||||||
71
packages/safe-chain/src/registryProxy/certUtils.spec.js
Normal file
71
packages/safe-chain/src/registryProxy/certUtils.spec.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("certUtils", () => {
|
||||||
|
let installedSafeChainDir;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
installedSafeChainDir = undefined;
|
||||||
|
mock.module("../config/safeChainDir.js", {
|
||||||
|
namedExports: {
|
||||||
|
getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain",
|
||||||
|
getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores CA certificates in the packaged install dir when available", async () => {
|
||||||
|
installedSafeChainDir = "/custom/safe-chain";
|
||||||
|
|
||||||
|
mock.module("fs", {
|
||||||
|
defaultExport: {
|
||||||
|
existsSync: () => false,
|
||||||
|
mkdirSync: () => {},
|
||||||
|
writeFileSync: () => {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("node-forge", {
|
||||||
|
defaultExport: {
|
||||||
|
pki: {
|
||||||
|
getPublicKeyFingerprint: () => "fingerprint",
|
||||||
|
rsa: {
|
||||||
|
generateKeyPair: () => ({
|
||||||
|
publicKey: "public-key",
|
||||||
|
privateKey: "private-key",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
createCertificate: () => ({
|
||||||
|
publicKey: null,
|
||||||
|
serialNumber: "",
|
||||||
|
validity: {
|
||||||
|
notBefore: new Date(),
|
||||||
|
notAfter: new Date(),
|
||||||
|
},
|
||||||
|
setSubject: () => {},
|
||||||
|
setIssuer: () => {},
|
||||||
|
setExtensions: () => {},
|
||||||
|
sign: () => {},
|
||||||
|
}),
|
||||||
|
privateKeyToPem: () => "private-key-pem",
|
||||||
|
certificateToPem: () => "certificate-pem",
|
||||||
|
},
|
||||||
|
md: {
|
||||||
|
sha1: { create: () => "sha1" },
|
||||||
|
sha256: { create: () => "sha256" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getCaCertPath } = await import("./certUtils.js");
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
getCaCertPath(),
|
||||||
|
"/custom/safe-chain/certs/ca-cert.pem",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,23 @@ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.j
|
||||||
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
||||||
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
|
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip conditional GET headers so PyPI always returns a full 200 response
|
||||||
|
* with a body we can rewrite. Without this, pip sends If-None-Match /
|
||||||
|
* If-Modified-Since, PyPI responds 304 Not Modified (empty body), and
|
||||||
|
* safe-chain cannot rewrite it — leaving pip with a cached index that still
|
||||||
|
* lists too-young versions. Those versions are then blocked at direct-download
|
||||||
|
* time with a hard 403, preventing dependency resolution from completing.
|
||||||
|
*
|
||||||
|
* @param {NodeJS.Dict<string | string[]>} headers
|
||||||
|
* @returns {NodeJS.Dict<string | string[]>}
|
||||||
|
*/
|
||||||
|
export function modifyPipInfoRequestHeaders(headers) {
|
||||||
|
delete headers["if-none-match"];
|
||||||
|
delete headers["if-modified-since"];
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
// Match simple-index anchor tags and capture their href so we can suppress
|
// Match simple-index anchor tags and capture their href so we can suppress
|
||||||
// individual distribution links from PyPI HTML metadata responses.
|
// individual distribution links from PyPI HTML metadata responses.
|
||||||
const HTML_ANCHOR_HREF_RE =
|
const HTML_ANCHOR_HREF_RE =
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.
|
||||||
import { interceptRequests } from "../interceptorBuilder.js";
|
import { interceptRequests } from "../interceptorBuilder.js";
|
||||||
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
|
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
|
||||||
import {
|
import {
|
||||||
|
modifyPipInfoRequestHeaders,
|
||||||
modifyPipInfoResponse,
|
modifyPipInfoResponse,
|
||||||
parsePipMetadataUrl,
|
parsePipMetadataUrl,
|
||||||
} from "./modifyPipInfo.js";
|
} from "./modifyPipInfo.js";
|
||||||
|
|
@ -61,6 +62,7 @@ function createPipRequestHandler(registry) {
|
||||||
!isExcludedFromMinimumPackageAge(metadataPackageName)
|
!isExcludedFromMinimumPackageAge(metadataPackageName)
|
||||||
) {
|
) {
|
||||||
const newPackagesDatabase = await openNewPackagesDatabase();
|
const newPackagesDatabase = await openNewPackagesDatabase();
|
||||||
|
reqContext.modifyRequestHeaders(modifyPipInfoRequestHeaders);
|
||||||
reqContext.modifyBody((body, headers) =>
|
reqContext.modifyBody((body, headers) =>
|
||||||
modifyPipInfoResponse(
|
modifyPipInfoResponse(
|
||||||
body,
|
body,
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,28 @@ describe("pipInterceptor minimum package age", async () => {
|
||||||
newlyReleasedPackageResponse = false;
|
newlyReleasedPackageResponse = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips If-None-Match and If-Modified-Since from metadata requests to prevent 304 cache bypass", async () => {
|
||||||
|
const url = "https://pypi.org/simple/foo-bar/";
|
||||||
|
newlyReleasedPackageResponse = true;
|
||||||
|
|
||||||
|
const interceptor = pipInterceptorForUrl(url);
|
||||||
|
const result = await interceptor.handleRequest(url);
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"if-none-match": '"some-etag"',
|
||||||
|
"if-modified-since": "Thu, 01 Jan 2026 00:00:00 GMT",
|
||||||
|
accept: "*/*",
|
||||||
|
};
|
||||||
|
|
||||||
|
result.modifyRequestHeaders(headers);
|
||||||
|
|
||||||
|
assert.equal(headers["if-none-match"], undefined, "If-None-Match must be stripped");
|
||||||
|
assert.equal(headers["if-modified-since"], undefined, "If-Modified-Since must be stripped");
|
||||||
|
assert.equal(headers.accept, "*/*", "unrelated headers must be preserved");
|
||||||
|
|
||||||
|
newlyReleasedPackageResponse = false;
|
||||||
|
});
|
||||||
|
|
||||||
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
|
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
|
||||||
const url =
|
const url =
|
||||||
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";
|
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ function getSafeChainProxyEnvironmentVariables() {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyUrl = `http://localhost:${state.port}`;
|
const proxyUrl = `http://127.0.0.1:${state.port}`;
|
||||||
const caCertPath = getCombinedCaBundlePath();
|
const caCertPath = getCombinedCaBundlePath();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -95,8 +95,11 @@ function createProxyServer() {
|
||||||
*/
|
*/
|
||||||
function startServer(server) {
|
function startServer(server) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// Passing port 0 makes the OS assign an available port
|
// Bind to loopback only. Without an explicit host, Node listens on every
|
||||||
server.listen(0, () => {
|
// interface, turning the proxy into an unauthenticated forward proxy that
|
||||||
|
// anyone reachable on the network can use to hit the victim's localhost,
|
||||||
|
// intranet, or cloud metadata endpoints. Port 0 lets the OS pick a port.
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
const address = server.address();
|
const address = server.address();
|
||||||
if (address && typeof address === "object") {
|
if (address && typeof address === "object") {
|
||||||
state.port = address.port;
|
state.port = address.port;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { before, after, describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import {
|
||||||
|
createSafeChainProxy,
|
||||||
|
mergeSafeChainProxyEnvironmentVariables,
|
||||||
|
} from "./registryProxy.js";
|
||||||
|
|
||||||
|
describe("registryProxy loopback binding", () => {
|
||||||
|
let proxy, proxyPort;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
proxy = createSafeChainProxy();
|
||||||
|
await proxy.startServer();
|
||||||
|
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
|
||||||
|
proxyPort = parseInt(new URL(envVars.HTTPS_PROXY).port, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await proxy.stopServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advertises a loopback HTTPS_PROXY URL", () => {
|
||||||
|
const envVars = mergeSafeChainProxyEnvironmentVariables([]);
|
||||||
|
const hostname = new URL(envVars.HTTPS_PROXY).hostname;
|
||||||
|
assert.ok(
|
||||||
|
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost",
|
||||||
|
`expected loopback hostname, got ${hostname}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses connections on non-loopback interfaces", async () => {
|
||||||
|
const externalAddrs = Object.values(os.networkInterfaces())
|
||||||
|
.flat()
|
||||||
|
.filter((iface) => iface && iface.family === "IPv4" && !iface.internal)
|
||||||
|
.map((iface) => iface.address);
|
||||||
|
|
||||||
|
if (externalAddrs.length === 0) {
|
||||||
|
// No non-loopback interface available (e.g. locked-down CI) - skip.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const addr of externalAddrs) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const sock = net.createConnection({ host: addr, port: proxyPort });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
sock.destroy();
|
||||||
|
resolve(); // Filtered / dropped is also fine - we just don't want success.
|
||||||
|
}, 500);
|
||||||
|
sock.once("connect", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
sock.destroy();
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`proxy accepted a connection on non-loopback ${addr}:${proxyPort}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
sock.once("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -15,8 +15,12 @@ import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
|
||||||
* @property {function(string, string): boolean} isMalware
|
* @property {function(string, string): boolean} isMalware
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {MalwareDatabase | null} */
|
// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
|
||||||
let cachedMalwareDatabase = null;
|
// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
|
||||||
|
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
|
||||||
|
// concurrent callers see it immediately and share a single fetch.
|
||||||
|
/** @type {Promise<MalwareDatabase> | null} */
|
||||||
|
let cachedMalwareDatabasePromise = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize package name for comparison.
|
* Normalize package name for comparison.
|
||||||
|
|
@ -34,13 +38,9 @@ function normalizePackageName(name) {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openMalwareDatabase() {
|
export function openMalwareDatabase() {
|
||||||
if (cachedMalwareDatabase) {
|
if (!cachedMalwareDatabasePromise) {
|
||||||
return cachedMalwareDatabase;
|
cachedMalwareDatabasePromise = getMalwareDatabase().then((malwareDatabase) => {
|
||||||
}
|
|
||||||
|
|
||||||
const malwareDatabase = await getMalwareDatabase();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
* @param {string} version
|
* @param {string} version
|
||||||
|
|
@ -63,16 +63,19 @@ export async function openMalwareDatabase() {
|
||||||
return packageData.reason;
|
return packageData.reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This implicitly caches the malware database
|
return {
|
||||||
// that's closed over by the getPackageStatus function
|
|
||||||
cachedMalwareDatabase = {
|
|
||||||
getPackageStatus,
|
getPackageStatus,
|
||||||
isMalware: (name, version) => {
|
isMalware: (/** @type {string} */ name, /** @type {string} */ version) => {
|
||||||
const status = getPackageStatus(name, version);
|
const status = getPackageStatus(name, version);
|
||||||
return isMalwareStatus(status);
|
return isMalwareStatus(status);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return cachedMalwareDatabase;
|
}).catch((error) => {
|
||||||
|
cachedMalwareDatabasePromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cachedMalwareDatabasePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -16,30 +16,27 @@ import { warnOnceAboutUnavailableDatabase } from "./newPackagesDatabaseWarnings.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Shared per-process cache to avoid rebuilding the same feed-backed database on each request.
|
// Shared per-process cache to avoid rebuilding the same feed-backed database on each request.
|
||||||
/** @type {NewPackagesDatabase | null} */
|
// Caching the Promise (rather than the resolved database) prevents duplicate fetches. If we cached the resolved
|
||||||
let cachedNewPackagesDatabase = null;
|
// value, multiple callers could pass the null-check before the first fetch completes (because each `await` yields
|
||||||
|
// control back to the event loop, allowing other callers to run). Since the Promise assignment is synchronous, all
|
||||||
|
// concurrent callers see it immediately and share a single fetch.
|
||||||
|
/** @type {Promise<NewPackagesDatabase> | null} */
|
||||||
|
let cachedNewPackagesDatabasePromise = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {Promise<NewPackagesDatabase>}
|
* @returns {Promise<NewPackagesDatabase>}
|
||||||
*/
|
*/
|
||||||
export async function openNewPackagesDatabase() {
|
export function openNewPackagesDatabase() {
|
||||||
if (cachedNewPackagesDatabase) {
|
if (!cachedNewPackagesDatabasePromise) {
|
||||||
return cachedNewPackagesDatabase;
|
cachedNewPackagesDatabasePromise = getNewPackagesList()
|
||||||
}
|
.then((newPackagesList) => buildNewPackagesDatabase(newPackagesList))
|
||||||
|
.catch((/** @type {any} */ error) => {
|
||||||
/** @type {import("../api/aikido.js").NewPackageEntry[]} */
|
|
||||||
let newPackagesList;
|
|
||||||
|
|
||||||
try {
|
|
||||||
newPackagesList = await getNewPackagesList();
|
|
||||||
} catch (/** @type {any} */ error) {
|
|
||||||
warnOnceAboutUnavailableDatabase(error);
|
warnOnceAboutUnavailableDatabase(error);
|
||||||
cachedNewPackagesDatabase = { isNewlyReleasedPackage: () => false };
|
cachedNewPackagesDatabasePromise = null;
|
||||||
return cachedNewPackagesDatabase;
|
return { isNewlyReleasedPackage: () => false };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return cachedNewPackagesDatabasePromise;
|
||||||
cachedNewPackagesDatabase = buildNewPackagesDatabase(newPackagesList);
|
|
||||||
return cachedNewPackagesDatabase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,18 @@ export const knownAikidoTools = [
|
||||||
ecoSystem: ECOSYSTEM_JS,
|
ecoSystem: ECOSYSTEM_JS,
|
||||||
internalPackageManagerName: "pnpx",
|
internalPackageManagerName: "pnpx",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tool: "rush",
|
||||||
|
aikidoCommand: "aikido-rush",
|
||||||
|
ecoSystem: ECOSYSTEM_JS,
|
||||||
|
internalPackageManagerName: "rush",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tool: "rushx",
|
||||||
|
aikidoCommand: "aikido-rushx",
|
||||||
|
ecoSystem: ECOSYSTEM_JS,
|
||||||
|
internalPackageManagerName: "rushx",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tool: "bun",
|
tool: "bun",
|
||||||
aikidoCommand: "aikido-bun",
|
aikidoCommand: "aikido-bun",
|
||||||
|
|
@ -66,6 +78,12 @@ export const knownAikidoTools = [
|
||||||
ecoSystem: ECOSYSTEM_PY,
|
ecoSystem: ECOSYSTEM_PY,
|
||||||
internalPackageManagerName: "uv",
|
internalPackageManagerName: "uv",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tool: "uvx",
|
||||||
|
aikidoCommand: "aikido-uvx",
|
||||||
|
ecoSystem: ECOSYSTEM_PY,
|
||||||
|
internalPackageManagerName: "uvx",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tool: "pip",
|
tool: "pip",
|
||||||
aikidoCommand: "aikido-pip",
|
aikidoCommand: "aikido-pip",
|
||||||
|
|
@ -102,6 +120,12 @@ export const knownAikidoTools = [
|
||||||
ecoSystem: ECOSYSTEM_PY,
|
ecoSystem: ECOSYSTEM_PY,
|
||||||
internalPackageManagerName: "pipx",
|
internalPackageManagerName: "pipx",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tool: "pdm",
|
||||||
|
aikidoCommand: "aikido-pdm",
|
||||||
|
ecoSystem: ECOSYSTEM_PY,
|
||||||
|
internalPackageManagerName: "pdm",
|
||||||
|
},
|
||||||
// When adding a new tool here, also update the documentation for the new tool in the README.md
|
// When adding a new tool here, also update the documentation for the new tool in the README.md
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -121,20 +145,6 @@ export function getPackageManagerList() {
|
||||||
return `${tools.join(", ")}, and ${lastTool} commands`;
|
return `${tools.join(", ")}, and ${lastTool} commands`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function getShimsDir() {
|
|
||||||
return path.join(os.homedir(), ".safe-chain", "shims");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export function getScriptsDir() {
|
|
||||||
return path.join(os.homedir(), ".safe-chain", "scripts");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} executableName
|
* @param {string} executableName
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir, homedir } from "node:os";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|
@ -15,6 +15,7 @@ describe("removeLinesMatchingPatternTests", () => {
|
||||||
mock.module("node:os", {
|
mock.module("node:os", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
EOL: "\r\n", // Simulate Windows line endings
|
EOL: "\r\n", // Simulate Windows line endings
|
||||||
|
homedir,
|
||||||
tmpdir: tmpdir,
|
tmpdir: tmpdir,
|
||||||
platform: () => "linux",
|
platform: () => "linux",
|
||||||
},
|
},
|
||||||
|
|
@ -182,3 +183,30 @@ describe("removeLinesMatchingPatternTests", () => {
|
||||||
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines");
|
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => {
|
||||||
|
it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => {
|
||||||
|
const { getSafeChainBaseDir } = await import("../config/safeChainDir.js");
|
||||||
|
assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getBinDir returns ~/.safe-chain/bin by default", async () => {
|
||||||
|
const { getBinDir } = await import("../config/safeChainDir.js");
|
||||||
|
assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getShimsDir returns ~/.safe-chain/shims by default", async () => {
|
||||||
|
const { getShimsDir } = await import("../config/safeChainDir.js");
|
||||||
|
assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => {
|
||||||
|
const { getScriptsDir } = await import("../config/safeChainDir.js");
|
||||||
|
assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getCertsDir returns ~/.safe-chain/certs by default", async () => {
|
||||||
|
const { getCertsDir } = await import("../config/safeChainDir.js");
|
||||||
|
assert.strictEqual(getCertsDir(), path.join(homedir(), ".safe-chain", "certs"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,31 @@
|
||||||
|
|
||||||
# Function to remove shim from PATH (POSIX-compliant)
|
# Function to remove shim from PATH (POSIX-compliant)
|
||||||
remove_shim_from_path() {
|
remove_shim_from_path() {
|
||||||
echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g"
|
_safe_chain_phys=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P)
|
||||||
|
if [ -z "$_safe_chain_phys" ]; then
|
||||||
|
echo "$PATH"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
_path=$(echo "$PATH" | sed "s|${_safe_chain_phys}:||g")
|
||||||
|
# Also remove via dirname of $0 directly — on macOS /tmp is a symlink to /private/tmp,
|
||||||
|
# so pwd -P resolves to /private/tmp/… but PATH may still contain /tmp/….
|
||||||
|
_dir=$(dirname -- "$0")
|
||||||
|
case "$_dir" in
|
||||||
|
/*) [ "$_dir" != "$_safe_chain_phys" ] && _path=$(echo "$_path" | sed "s|${_dir}:||g") ;;
|
||||||
|
esac
|
||||||
|
echo "$_path"
|
||||||
}
|
}
|
||||||
|
|
||||||
if command -v safe-chain >/dev/null 2>&1; then
|
if command -v safe-chain >/dev/null 2>&1; then
|
||||||
# Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops
|
# Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops.
|
||||||
|
# Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't
|
||||||
|
# mistake argv[1] for a script path and try to resolve "{{PACKAGE_MANAGER}}" against cwd.
|
||||||
|
unset PKG_EXECPATH
|
||||||
PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@"
|
PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@"
|
||||||
else
|
else
|
||||||
|
# safe-chain is not reachable — warn the user so they know protection is inactive
|
||||||
|
printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. {{PACKAGE_MANAGER}} will run without it.\n" >&2
|
||||||
|
|
||||||
# Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory)
|
# Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory)
|
||||||
original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}})
|
original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}})
|
||||||
if [ -n "$original_cmd" ]; then
|
if [ -n "$original_cmd" ]; then
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain
|
||||||
REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments
|
REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments
|
||||||
|
|
||||||
REM Remove shim directory from PATH to prevent infinite loops
|
REM Remove shim directory from PATH to prevent infinite loops
|
||||||
set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims"
|
set "SHIM_DIR=%~dp0"
|
||||||
|
if "%SHIM_DIR:~-1%"=="\" set "SHIM_DIR=%SHIM_DIR:~0,-1%"
|
||||||
call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%"
|
call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%"
|
||||||
|
|
||||||
REM Check if aikido command is available with clean PATH
|
REM Check if aikido command is available with clean PATH
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||||
|
|
||||||
|
describe("PKG_EXECPATH cleanup", () => {
|
||||||
|
it("unix shim template unsets PKG_EXECPATH before invoking safe-chain", () => {
|
||||||
|
const file = path.join(
|
||||||
|
repoRoot,
|
||||||
|
"src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh",
|
||||||
|
);
|
||||||
|
const content = fs.readFileSync(file, "utf-8");
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/unset PKG_EXECPATH[\s\S]*exec safe-chain/,
|
||||||
|
"unix-wrapper.template.sh must `unset PKG_EXECPATH` before `exec safe-chain`",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posix shell function unsets PKG_EXECPATH before invoking safe-chain", () => {
|
||||||
|
const file = path.join(
|
||||||
|
repoRoot,
|
||||||
|
"src/shell-integration/startup-scripts/init-posix.sh",
|
||||||
|
);
|
||||||
|
const content = fs.readFileSync(file, "utf-8");
|
||||||
|
// Scoped subshell so we don't mutate the user's interactive env.
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/\(unset PKG_EXECPATH;\s*safe-chain "\$@"\)/,
|
||||||
|
"init-posix.sh must invoke safe-chain in a subshell that unsets PKG_EXECPATH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fish shell function unsets PKG_EXECPATH before invoking safe-chain", () => {
|
||||||
|
const file = path.join(
|
||||||
|
repoRoot,
|
||||||
|
"src/shell-integration/startup-scripts/init-fish.fish",
|
||||||
|
);
|
||||||
|
const content = fs.readFileSync(file, "utf-8");
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/env -u PKG_EXECPATH safe-chain/,
|
||||||
|
"init-fish.fish must invoke safe-chain via `env -u PKG_EXECPATH`",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safe-chain entry point deletes PKG_EXECPATH from process.env", () => {
|
||||||
|
const file = path.join(repoRoot, "bin/safe-chain.js");
|
||||||
|
const content = fs.readFileSync(file, "utf-8");
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/delete process\.env\.PKG_EXECPATH/,
|
||||||
|
"bin/safe-chain.js must delete process.env.PKG_EXECPATH so spawned children don't inherit it",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,24 +1,14 @@
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js";
|
import { getPackageManagerList, knownAikidoTools } from "./helpers.js";
|
||||||
|
import {
|
||||||
|
getShimsDir,
|
||||||
|
getBinDir,
|
||||||
|
getPathWrapperTemplatePath,
|
||||||
|
} from "../config/safeChainDir.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
|
|
||||||
/** @type {string} */
|
|
||||||
// This checks the current file's dirname in a way that's compatible with:
|
|
||||||
// - Modulejs (import.meta.url)
|
|
||||||
// - ES modules (__dirname)
|
|
||||||
// This is needed because safe-chain's npm package is built using ES modules,
|
|
||||||
// but building the binaries requires commonjs.
|
|
||||||
let dirname;
|
|
||||||
if (import.meta.url) {
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
|
||||||
dirname = path.dirname(filename);
|
|
||||||
} else {
|
|
||||||
dirname = __dirname;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loops over the detected shells and calls the setup function for each.
|
* Loops over the detected shells and calls the setup function for each.
|
||||||
|
|
@ -31,7 +21,7 @@ export async function setupCi() {
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
|
|
||||||
const shimsDir = getShimsDir();
|
const shimsDir = getShimsDir();
|
||||||
const binDir = path.join(os.homedir(), ".safe-chain", "bin");
|
const binDir = getBinDir();
|
||||||
// Create the shims directory if it doesn't exist
|
// Create the shims directory if it doesn't exist
|
||||||
if (!fs.existsSync(shimsDir)) {
|
if (!fs.existsSync(shimsDir)) {
|
||||||
fs.mkdirSync(shimsDir, { recursive: true });
|
fs.mkdirSync(shimsDir, { recursive: true });
|
||||||
|
|
@ -50,12 +40,7 @@ export async function setupCi() {
|
||||||
*/
|
*/
|
||||||
function createUnixShims(shimsDir) {
|
function createUnixShims(shimsDir) {
|
||||||
// Read the template file
|
// Read the template file
|
||||||
const templatePath = path.resolve(
|
const templatePath = getPathWrapperTemplatePath(import.meta.url, "unix-wrapper.template.sh");
|
||||||
dirname,
|
|
||||||
"path-wrappers",
|
|
||||||
"templates",
|
|
||||||
"unix-wrapper.template.sh"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!fs.existsSync(templatePath)) {
|
if (!fs.existsSync(templatePath)) {
|
||||||
ui.writeError(`Template file not found: ${templatePath}`);
|
ui.writeError(`Template file not found: ${templatePath}`);
|
||||||
|
|
@ -89,12 +74,7 @@ function createUnixShims(shimsDir) {
|
||||||
*/
|
*/
|
||||||
function createWindowsShims(shimsDir) {
|
function createWindowsShims(shimsDir) {
|
||||||
// Read the template file
|
// Read the template file
|
||||||
const templatePath = path.resolve(
|
const templatePath = getPathWrapperTemplatePath(import.meta.url, "windows-wrapper.template.cmd");
|
||||||
dirname,
|
|
||||||
"path-wrappers",
|
|
||||||
"templates",
|
|
||||||
"windows-wrapper.template.cmd"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!fs.existsSync(templatePath)) {
|
if (!fs.existsSync(templatePath)) {
|
||||||
ui.writeError(`Windows template file not found: ${templatePath}`);
|
ui.writeError(`Windows template file not found: ${templatePath}`);
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,12 @@ describe("Setup CI shell integration", () => {
|
||||||
fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true });
|
fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"),
|
path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"),
|
||||||
"#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\nexec {{AIKIDO_COMMAND}} \"$@\"\n",
|
"#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\n_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)\nexec {{AIKIDO_COMMAND}} \"$@\"\n",
|
||||||
"utf-8"
|
"utf-8"
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"),
|
path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"),
|
||||||
"@echo off\nREM Template for {{PACKAGE_MANAGER}}\n{{AIKIDO_COMMAND}} %*\n",
|
"@echo off\nset \"SHIM_DIR=%~dp0\"\n{{AIKIDO_COMMAND}} %*\n",
|
||||||
"utf-8"
|
"utf-8"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -50,7 +50,15 @@ describe("Setup CI shell integration", () => {
|
||||||
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
{ tool: "yarn", aikidoCommand: "aikido-yarn" },
|
||||||
],
|
],
|
||||||
getPackageManagerList: () => "npm, yarn",
|
getPackageManagerList: () => "npm, yarn",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mock.module("../config/safeChainDir.js", {
|
||||||
|
namedExports: {
|
||||||
getShimsDir: () => mockShimsDir,
|
getShimsDir: () => mockShimsDir,
|
||||||
|
getBinDir: () => path.join(mockHomeDir, ".safe-chain", "bin"),
|
||||||
|
getPathWrapperTemplatePath: (_moduleUrl, fileName) =>
|
||||||
|
path.join(mockTemplateDir, "path-wrappers", "templates", fileName),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -63,22 +71,6 @@ describe("Setup CI shell integration", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock path module to resolve templates correctly
|
|
||||||
mock.module("path", {
|
|
||||||
namedExports: {
|
|
||||||
join: path.join,
|
|
||||||
dirname: () => mockTemplateDir,
|
|
||||||
resolve: (...args) => path.resolve(mockTemplateDir, ...args.slice(1)),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock fileURLToPath
|
|
||||||
mock.module("url", {
|
|
||||||
namedExports: {
|
|
||||||
fileURLToPath: () => path.join(mockTemplateDir, "setup-ci.js"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import setupCi module after mocking
|
// Import setupCi module after mocking
|
||||||
setupCi = (await import("./setup-ci.js")).setupCi;
|
setupCi = (await import("./setup-ci.js")).setupCi;
|
||||||
});
|
});
|
||||||
|
|
@ -119,6 +111,10 @@ describe("Setup CI shell integration", () => {
|
||||||
const npmShimContent = fs.readFileSync(npmShimPath, "utf-8");
|
const npmShimContent = fs.readFileSync(npmShimPath, "utf-8");
|
||||||
assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm");
|
assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm");
|
||||||
assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang");
|
assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang");
|
||||||
|
assert.ok(
|
||||||
|
npmShimContent.includes("_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)"),
|
||||||
|
"npm shim should derive the shims directory from its own location",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should create Windows .cmd shims on win32 platform", async () => {
|
it("should create Windows .cmd shims on win32 platform", async () => {
|
||||||
|
|
@ -142,6 +138,10 @@ describe("Setup CI shell integration", () => {
|
||||||
assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm");
|
assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm");
|
||||||
assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header");
|
assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header");
|
||||||
assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing");
|
assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing");
|
||||||
|
assert.ok(
|
||||||
|
npmShimContent.includes('set "SHIM_DIR=%~dp0"'),
|
||||||
|
"npm.cmd should derive the shims directory from its own location",
|
||||||
|
);
|
||||||
|
|
||||||
// Verify Unix shims were NOT created
|
// Verify Unix shims were NOT created
|
||||||
const unixNpmShim = path.join(mockShimsDir, "npm");
|
const unixNpmShim = path.join(mockShimsDir, "npm");
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,10 @@
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
import { detectShells } from "./shellDetection.js";
|
import { detectShells } from "./shellDetection.js";
|
||||||
import {
|
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
|
||||||
knownAikidoTools,
|
import { getScriptsDir, getStartupScriptSourcePath } from "../config/safeChainDir.js";
|
||||||
getPackageManagerList,
|
|
||||||
getScriptsDir,
|
|
||||||
} from "./helpers.js";
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
|
|
||||||
/** @type {string} */
|
|
||||||
// This checks the current file's dirname in a way that's compatible with:
|
|
||||||
// - Modulejs (import.meta.url)
|
|
||||||
// - ES modules (__dirname)
|
|
||||||
// This is needed because safe-chain's npm package is built using ES modules,
|
|
||||||
// but building the binaries requires commonjs.
|
|
||||||
let dirname;
|
|
||||||
if (import.meta.url) {
|
|
||||||
const filename = fileURLToPath(import.meta.url);
|
|
||||||
dirname = path.dirname(filename);
|
|
||||||
} else {
|
|
||||||
dirname = __dirname;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loops over the detected shells and calls the setup function for each.
|
* Loops over the detected shells and calls the setup function for each.
|
||||||
|
|
@ -122,8 +104,7 @@ function copyStartupFiles() {
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use absolute path for source
|
const sourcePath = getStartupScriptSourcePath(import.meta.url, file);
|
||||||
const sourcePath = path.join(dirname, "startup-scripts", file);
|
|
||||||
fs.copyFileSync(sourcePath, targetPath);
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
set -gx PATH $PATH $HOME/.safe-chain/bin
|
set -l safe_chain_script (status filename)
|
||||||
|
set -l safe_chain_scripts_dir (dirname $safe_chain_script)
|
||||||
|
set -l safe_chain_base (dirname $safe_chain_scripts_dir)
|
||||||
|
set -gx PATH $PATH $safe_chain_base/bin
|
||||||
|
|
||||||
function npx
|
function npx
|
||||||
wrapSafeChainCommand "npx" $argv
|
wrapSafeChainCommand "npx" $argv
|
||||||
|
|
@ -16,6 +19,14 @@ function pnpx
|
||||||
wrapSafeChainCommand "pnpx" $argv
|
wrapSafeChainCommand "pnpx" $argv
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function rush
|
||||||
|
wrapSafeChainCommand "rush" $argv
|
||||||
|
end
|
||||||
|
|
||||||
|
function rushx
|
||||||
|
wrapSafeChainCommand "rushx" $argv
|
||||||
|
end
|
||||||
|
|
||||||
function bun
|
function bun
|
||||||
wrapSafeChainCommand "bun" $argv
|
wrapSafeChainCommand "bun" $argv
|
||||||
end
|
end
|
||||||
|
|
@ -51,6 +62,10 @@ function uv
|
||||||
wrapSafeChainCommand "uv" $argv
|
wrapSafeChainCommand "uv" $argv
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function uvx
|
||||||
|
wrapSafeChainCommand "uvx" $argv
|
||||||
|
end
|
||||||
|
|
||||||
function poetry
|
function poetry
|
||||||
wrapSafeChainCommand "poetry" $argv
|
wrapSafeChainCommand "poetry" $argv
|
||||||
end
|
end
|
||||||
|
|
@ -69,6 +84,10 @@ function pipx
|
||||||
wrapSafeChainCommand "pipx" $argv
|
wrapSafeChainCommand "pipx" $argv
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function pdm
|
||||||
|
wrapSafeChainCommand "pdm" $argv
|
||||||
|
end
|
||||||
|
|
||||||
function printSafeChainWarning
|
function printSafeChainWarning
|
||||||
set original_cmd $argv[1]
|
set original_cmd $argv[1]
|
||||||
|
|
||||||
|
|
@ -105,8 +124,10 @@ function wrapSafeChainCommand
|
||||||
end
|
end
|
||||||
|
|
||||||
if type -q safe-chain
|
if type -q safe-chain
|
||||||
# If the safe-chain command is available, just run it with the provided arguments
|
# If the safe-chain command is available, just run it with the provided arguments.
|
||||||
safe-chain $original_cmd $cmd_args
|
# Unset PKG_EXECPATH for this invocation so the yao-pkg bootstrap inside the
|
||||||
|
# safe-chain binary doesn't mistake argv[1] for a script path to resolve against cwd.
|
||||||
|
env -u PKG_EXECPATH safe-chain $original_cmd $cmd_args
|
||||||
else
|
else
|
||||||
# If the safe-chain command is not available, print a warning and run the original command
|
# If the safe-chain command is not available, print a warning and run the original command
|
||||||
printSafeChainWarning $original_cmd
|
printSafeChainWarning $original_cmd
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
export PATH="$PATH:$HOME/.safe-chain/bin"
|
if [ -n "${BASH_SOURCE[0]:-}" ]; then
|
||||||
|
_sc_script_path="${BASH_SOURCE[0]}"
|
||||||
|
elif [ -n "${ZSH_VERSION:-}" ]; then
|
||||||
|
# ${(%):-%x} uses Zsh prompt expansion to get the sourced file's path.
|
||||||
|
# eval is required so other shells don't try to parse the Zsh-specific syntax.
|
||||||
|
eval '_sc_script_path="${(%):-%x}"'
|
||||||
|
else
|
||||||
|
_sc_script_path="$0"
|
||||||
|
fi
|
||||||
|
_sc_scripts_dir=$(CDPATH= cd -- "$(dirname -- "$_sc_script_path")" 2>/dev/null && pwd -P)
|
||||||
|
_sc_base=$(dirname -- "$_sc_scripts_dir")
|
||||||
|
export PATH="$PATH:${_sc_base}/bin"
|
||||||
|
unset _sc_base _sc_script_path _sc_scripts_dir
|
||||||
|
|
||||||
function npx() {
|
function npx() {
|
||||||
wrapSafeChainCommand "npx" "$@"
|
wrapSafeChainCommand "npx" "$@"
|
||||||
|
|
@ -16,6 +28,14 @@ function pnpx() {
|
||||||
wrapSafeChainCommand "pnpx" "$@"
|
wrapSafeChainCommand "pnpx" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rush() {
|
||||||
|
wrapSafeChainCommand "rush" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
function rushx() {
|
||||||
|
wrapSafeChainCommand "rushx" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
function bun() {
|
function bun() {
|
||||||
wrapSafeChainCommand "bun" "$@"
|
wrapSafeChainCommand "bun" "$@"
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +67,10 @@ function uv() {
|
||||||
wrapSafeChainCommand "uv" "$@"
|
wrapSafeChainCommand "uv" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uvx() {
|
||||||
|
wrapSafeChainCommand "uvx" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
function poetry() {
|
function poetry() {
|
||||||
wrapSafeChainCommand "poetry" "$@"
|
wrapSafeChainCommand "poetry" "$@"
|
||||||
}
|
}
|
||||||
|
|
@ -65,6 +89,10 @@ function pipx() {
|
||||||
wrapSafeChainCommand "pipx" "$@"
|
wrapSafeChainCommand "pipx" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pdm() {
|
||||||
|
wrapSafeChainCommand "pdm" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
function printSafeChainWarning() {
|
function printSafeChainWarning() {
|
||||||
# \033[43;30m is used to set the background color to yellow and text color to black
|
# \033[43;30m is used to set the background color to yellow and text color to black
|
||||||
# \033[0m is used to reset the text formatting
|
# \033[0m is used to reset the text formatting
|
||||||
|
|
@ -85,8 +113,10 @@ function wrapSafeChainCommand() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v safe-chain > /dev/null 2>&1; then
|
if command -v safe-chain > /dev/null 2>&1; then
|
||||||
# If the aikido command is available, just run it with the provided arguments
|
# If the aikido command is available, just run it with the provided arguments.
|
||||||
safe-chain "$@"
|
# Unset PKG_EXECPATH so the yao-pkg bootstrap inside the safe-chain binary doesn't
|
||||||
|
# mistake argv[1] for a script path and try to resolve it against cwd.
|
||||||
|
(unset PKG_EXECPATH; safe-chain "$@")
|
||||||
else
|
else
|
||||||
# If the aikido command is not available, print a warning and run the original command
|
# If the aikido command is not available, print a warning and run the original command
|
||||||
printSafeChainWarning "$original_cmd"
|
printSafeChainWarning "$original_cmd"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
# $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell
|
# $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell
|
||||||
$isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true }
|
$isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true }
|
||||||
$pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' }
|
$pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' }
|
||||||
$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
|
$safeChainBase = Split-Path -Parent $PSScriptRoot
|
||||||
|
$safeChainBin = Join-Path $safeChainBase 'bin'
|
||||||
$env:PATH = "$env:PATH$pathSeparator$safeChainBin"
|
$env:PATH = "$env:PATH$pathSeparator$safeChainBin"
|
||||||
|
|
||||||
function npx {
|
function npx {
|
||||||
|
|
@ -21,6 +22,14 @@ function pnpx {
|
||||||
Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rush {
|
||||||
|
Invoke-WrappedCommand "rush" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||||
|
}
|
||||||
|
|
||||||
|
function rushx {
|
||||||
|
Invoke-WrappedCommand "rushx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||||
|
}
|
||||||
|
|
||||||
function bun {
|
function bun {
|
||||||
Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +61,10 @@ function uv {
|
||||||
Invoke-WrappedCommand "uv" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
Invoke-WrappedCommand "uv" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uvx {
|
||||||
|
Invoke-WrappedCommand "uvx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||||
|
}
|
||||||
|
|
||||||
function poetry {
|
function poetry {
|
||||||
Invoke-WrappedCommand "poetry" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
Invoke-WrappedCommand "poetry" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +83,10 @@ function pipx {
|
||||||
Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pdm {
|
||||||
|
Invoke-WrappedCommand "pdm" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
||||||
|
}
|
||||||
|
|
||||||
function Write-SafeChainWarning {
|
function Write-SafeChainWarning {
|
||||||
param([string]$Command)
|
param([string]$Command)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import {
|
||||||
doesExecutableExistOnSystem,
|
doesExecutableExistOnSystem,
|
||||||
removeLinesMatchingPattern,
|
removeLinesMatchingPattern,
|
||||||
} from "../helpers.js";
|
} from "../helpers.js";
|
||||||
|
import { getScriptsDir } from "../../config/safeChainDir.js";
|
||||||
import { execSync, spawnSync } from "child_process";
|
import { execSync, spawnSync } from "child_process";
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const shellName = "Bash";
|
const shellName = "Bash";
|
||||||
const executableName = "bash";
|
const executableName = "bash";
|
||||||
|
|
@ -32,10 +34,10 @@ function teardown(tools) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removes the line that sources the safe-chain bash initialization script (~/.safe-chain/scripts/init-posix.sh)
|
// Remove sourcing line to disable safe-chain shell integration
|
||||||
removeLinesMatchingPattern(
|
removeLinesMatchingPattern(
|
||||||
startupFile,
|
startupFile,
|
||||||
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/,
|
/^source\s+.*init-posix\.sh.*#\s*Safe-chain/,
|
||||||
eol
|
eol
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -44,10 +46,11 @@ function teardown(tools) {
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const startupFile = getStartupFile();
|
const startupFile = getStartupFile();
|
||||||
|
const scriptsDir = getShellScriptsDir();
|
||||||
|
|
||||||
addLineToFile(
|
addLineToFile(
|
||||||
startupFile,
|
startupFile,
|
||||||
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`,
|
`source ${path.posix.join(scriptsDir, "init-posix.sh")} # Safe-chain bash initialization script`,
|
||||||
eol
|
eol
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -94,6 +97,51 @@ function windowsFixPath(path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getShellScriptsDir() {
|
||||||
|
return toBashPath(getScriptsDir());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function toBashPath(path) {
|
||||||
|
try {
|
||||||
|
if (os.platform() !== "win32") {
|
||||||
|
return path.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const directWindowsPath = windowsPathToBashPath(path);
|
||||||
|
if (directWindowsPath) {
|
||||||
|
return directWindowsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCygpath()) {
|
||||||
|
return convertCygwinPathToUnix(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.replace(/\\/g, "/");
|
||||||
|
} catch {
|
||||||
|
return path.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path
|
||||||
|
*
|
||||||
|
* @returns {string | undefined}
|
||||||
|
*/
|
||||||
|
function windowsPathToBashPath(path) {
|
||||||
|
const match = /^([A-Za-z]):[\\/](.*)$/.exec(path);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, driveLetter, rest] = match;
|
||||||
|
return `/${driveLetter.toLowerCase()}/${rest.replace(/\\/g, "/")}`;
|
||||||
|
}
|
||||||
|
|
||||||
function hasCygpath() {
|
function hasCygpath() {
|
||||||
try {
|
try {
|
||||||
var result = spawnSync("where", ["cygpath"], { shell: executableName });
|
var result = spawnSync("where", ["cygpath"], { shell: executableName });
|
||||||
|
|
@ -123,18 +171,40 @@ function cygpathw(path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} path
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function convertCygwinPathToUnix(path) {
|
||||||
|
try {
|
||||||
|
var result = spawnSync("cygpath", ["-u", path], {
|
||||||
|
encoding: "utf8",
|
||||||
|
shell: executableName,
|
||||||
|
});
|
||||||
|
if (result.status === 0) {
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
return path.replace(/\\/g, "/");
|
||||||
|
} catch {
|
||||||
|
return path.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getManualTeardownInstructions() {
|
function getManualTeardownInstructions() {
|
||||||
|
const scriptsDir = getShellScriptsDir();
|
||||||
return [
|
return [
|
||||||
`Remove the following line from your ~/.bashrc file:`,
|
`Remove the following line from your ~/.bashrc file:`,
|
||||||
` source ~/.safe-chain/scripts/init-posix.sh`,
|
` source ${path.posix.join(scriptsDir, "init-posix.sh")}`,
|
||||||
`Then restart your terminal or run: source ~/.bashrc`,
|
`Then restart your terminal or run: source ~/.bashrc`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getManualSetupInstructions() {
|
function getManualSetupInstructions() {
|
||||||
|
const scriptsDir = getShellScriptsDir();
|
||||||
return [
|
return [
|
||||||
`Add the following line to your ~/.bashrc file:`,
|
`Add the following line to your ~/.bashrc file:`,
|
||||||
` source ~/.safe-chain/scripts/init-posix.sh`,
|
` source ${path.posix.join(scriptsDir, "init-posix.sh")}`,
|
||||||
`Then restart your terminal or run: source ~/.bashrc`,
|
`Then restart your terminal or run: source ~/.bashrc`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ describe("Bash shell integration", () => {
|
||||||
let mockStartupFile;
|
let mockStartupFile;
|
||||||
let bash;
|
let bash;
|
||||||
let windowsCygwinPath = "";
|
let windowsCygwinPath = "";
|
||||||
|
let mockScriptsDir = "/test-home/.safe-chain/scripts";
|
||||||
let platform = "linux";
|
let platform = "linux";
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
@ -35,6 +36,12 @@ describe("Bash shell integration", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mock.module("../../config/safeChainDir.js", {
|
||||||
|
namedExports: {
|
||||||
|
getScriptsDir: () => mockScriptsDir,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Mock child_process execSync
|
// Mock child_process execSync
|
||||||
mock.module("child_process", {
|
mock.module("child_process", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
|
|
@ -61,6 +68,17 @@ describe("Bash shell integration", () => {
|
||||||
stdout: windowsCygwinPath + "\n",
|
stdout: windowsCygwinPath + "\n",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
command === "cygpath" &&
|
||||||
|
args[0] === "-u" &&
|
||||||
|
args[1] === mockScriptsDir
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: "/c/test-home/.safe-chain/scripts\n",
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -87,6 +105,7 @@ describe("Bash shell integration", () => {
|
||||||
|
|
||||||
// Reset mocks
|
// Reset mocks
|
||||||
mock.reset();
|
mock.reset();
|
||||||
|
mockScriptsDir = "/test-home/.safe-chain/scripts";
|
||||||
platform = "linux";
|
platform = "linux";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -109,7 +128,7 @@ describe("Bash shell integration", () => {
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes(
|
content.includes(
|
||||||
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
|
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -129,7 +148,24 @@ describe("Bash shell integration", () => {
|
||||||
const content = fs.readFileSync(windowsCygwinPath, "utf-8");
|
const content = fs.readFileSync(windowsCygwinPath, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes(
|
content.includes(
|
||||||
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
|
"source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should write a bash-compatible scripts path on Windows", () => {
|
||||||
|
platform = "win32";
|
||||||
|
windowsCygwinPath = mockStartupFile;
|
||||||
|
mockScriptsDir = "C:\\test-home\\.safe-chain\\scripts";
|
||||||
|
mockStartupFile = "DUMMY";
|
||||||
|
|
||||||
|
const result = bash.setup();
|
||||||
|
assert.strictEqual(result, true);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(windowsCygwinPath, "utf-8");
|
||||||
|
assert.ok(
|
||||||
|
content.includes(
|
||||||
|
"source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -209,13 +245,13 @@ describe("Bash shell integration", () => {
|
||||||
// Setup
|
// Setup
|
||||||
bash.setup(tools);
|
bash.setup(tools);
|
||||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
|
assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh"));
|
||||||
|
|
||||||
// Teardown
|
// Teardown
|
||||||
bash.teardown(tools);
|
bash.teardown(tools);
|
||||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -236,7 +272,7 @@ describe("Bash shell integration", () => {
|
||||||
const initialContent = [
|
const initialContent = [
|
||||||
"#!/bin/bash",
|
"#!/bin/bash",
|
||||||
"alias npm='old-npm'",
|
"alias npm='old-npm'",
|
||||||
"source ~/.safe-chain/scripts/init-posix.sh",
|
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script",
|
||||||
"alias ls='ls --color=auto'",
|
"alias ls='ls --color=auto'",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
|
@ -247,7 +283,7 @@ describe("Bash shell integration", () => {
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(!content.includes("alias npm="));
|
assert.ok(!content.includes("alias npm="));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script")
|
||||||
);
|
);
|
||||||
assert.ok(content.includes("alias ls="));
|
assert.ok(content.includes("alias ls="));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import {
|
||||||
doesExecutableExistOnSystem,
|
doesExecutableExistOnSystem,
|
||||||
removeLinesMatchingPattern,
|
removeLinesMatchingPattern,
|
||||||
} from "../helpers.js";
|
} from "../helpers.js";
|
||||||
|
import { getScriptsDir } from "../../config/safeChainDir.js";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const shellName = "Fish";
|
const shellName = "Fish";
|
||||||
const executableName = "fish";
|
const executableName = "fish";
|
||||||
|
|
@ -31,10 +33,10 @@ function teardown(tools) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removes the line that sources the safe-chain fish initialization script (~/.safe-chain/scripts/init-fish.fish)
|
// Remove sourcing line to prevent safe-chain initialization in future shell sessions
|
||||||
removeLinesMatchingPattern(
|
removeLinesMatchingPattern(
|
||||||
startupFile,
|
startupFile,
|
||||||
/^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/,
|
/^source\s+.*init-fish\.fish.*#\s*Safe-chain/,
|
||||||
eol
|
eol
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -46,7 +48,7 @@ function setup() {
|
||||||
|
|
||||||
addLineToFile(
|
addLineToFile(
|
||||||
startupFile,
|
startupFile,
|
||||||
`source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`,
|
`source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`,
|
||||||
eol
|
eol
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -69,7 +71,7 @@ function getStartupFile() {
|
||||||
function getManualTeardownInstructions() {
|
function getManualTeardownInstructions() {
|
||||||
return [
|
return [
|
||||||
`Remove the following line from your ~/.config/fish/config.fish file:`,
|
`Remove the following line from your ~/.config/fish/config.fish file:`,
|
||||||
` source ~/.safe-chain/scripts/init-fish.fish`,
|
` source ${path.join(getScriptsDir(), "init-fish.fish")}`,
|
||||||
`Then restart your terminal or run: source ~/.config/fish/config.fish`,
|
`Then restart your terminal or run: source ~/.config/fish/config.fish`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +79,7 @@ function getManualTeardownInstructions() {
|
||||||
function getManualSetupInstructions() {
|
function getManualSetupInstructions() {
|
||||||
return [
|
return [
|
||||||
`Add the following line to your ~/.config/fish/config.fish file:`,
|
`Add the following line to your ~/.config/fish/config.fish file:`,
|
||||||
` source ~/.safe-chain/scripts/init-fish.fish`,
|
` source ${path.join(getScriptsDir(), "init-fish.fish")}`,
|
||||||
`Then restart your terminal or run: source ~/.config/fish/config.fish`,
|
`Then restart your terminal or run: source ~/.config/fish/config.fish`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ describe("Fish shell integration", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mock.module("../../config/safeChainDir.js", {
|
||||||
|
namedExports: {
|
||||||
|
getScriptsDir: () => "/test-home/.safe-chain/scripts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Mock child_process execSync
|
// Mock child_process execSync
|
||||||
mock.module("child_process", {
|
mock.module("child_process", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
|
|
@ -72,7 +78,7 @@ describe("Fish shell integration", () => {
|
||||||
|
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes('source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script')
|
content.includes('source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -81,7 +87,7 @@ describe("Fish shell integration", () => {
|
||||||
fish.setup();
|
fish.setup();
|
||||||
|
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
|
const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
|
||||||
assert.strictEqual(sourceMatches, 2, "Should allow multiple source lines (helper doesn't dedupe)");
|
assert.strictEqual(sourceMatches, 2, "Should allow multiple source lines (helper doesn't dedupe)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -93,7 +99,7 @@ describe("Fish shell integration", () => {
|
||||||
"alias npm 'aikido-npm'",
|
"alias npm 'aikido-npm'",
|
||||||
"alias npx 'aikido-npx'",
|
"alias npx 'aikido-npx'",
|
||||||
"alias yarn 'aikido-yarn'",
|
"alias yarn 'aikido-yarn'",
|
||||||
"source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script",
|
"source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script",
|
||||||
"alias ls 'ls --color=auto'",
|
"alias ls 'ls --color=auto'",
|
||||||
"alias grep 'grep --color=auto'",
|
"alias grep 'grep --color=auto'",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
@ -107,7 +113,7 @@ describe("Fish shell integration", () => {
|
||||||
assert.ok(!content.includes("alias npm "));
|
assert.ok(!content.includes("alias npm "));
|
||||||
assert.ok(!content.includes("alias npx "));
|
assert.ok(!content.includes("alias npx "));
|
||||||
assert.ok(!content.includes("alias yarn "));
|
assert.ok(!content.includes("alias yarn "));
|
||||||
assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish"));
|
assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish"));
|
||||||
assert.ok(content.includes("alias ls "));
|
assert.ok(content.includes("alias ls "));
|
||||||
assert.ok(content.includes("alias grep "));
|
assert.ok(content.includes("alias grep "));
|
||||||
});
|
});
|
||||||
|
|
@ -162,12 +168,12 @@ describe("Fish shell integration", () => {
|
||||||
// Setup
|
// Setup
|
||||||
fish.setup();
|
fish.setup();
|
||||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(content.includes('source ~/.safe-chain/scripts/init-fish.fish'));
|
assert.ok(content.includes('source /test-home/.safe-chain/scripts/init-fish.fish'));
|
||||||
|
|
||||||
// Teardown
|
// Teardown
|
||||||
fish.teardown(tools);
|
fish.teardown(tools);
|
||||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish"));
|
assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle multiple setup calls", () => {
|
it("should handle multiple setup calls", () => {
|
||||||
|
|
@ -176,7 +182,7 @@ describe("Fish shell integration", () => {
|
||||||
fish.setup();
|
fish.setup();
|
||||||
|
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
|
const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length;
|
||||||
assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle");
|
assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import {
|
||||||
removeLinesMatchingPattern,
|
removeLinesMatchingPattern,
|
||||||
validatePowerShellExecutionPolicy,
|
validatePowerShellExecutionPolicy,
|
||||||
} from "../helpers.js";
|
} from "../helpers.js";
|
||||||
|
import { getScriptsDir } from "../../config/safeChainDir.js";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const shellName = "PowerShell Core";
|
const shellName = "PowerShell Core";
|
||||||
const executableName = "pwsh";
|
const executableName = "pwsh";
|
||||||
|
|
@ -30,10 +32,10 @@ function teardown(tools) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the line that sources the safe-chain PowerShell initialization script
|
// Remove sourcing line to prevent shell from loading safe-chain after uninstallation
|
||||||
removeLinesMatchingPattern(
|
removeLinesMatchingPattern(
|
||||||
startupFile,
|
startupFile,
|
||||||
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/,
|
/^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -52,7 +54,7 @@ async function setup() {
|
||||||
|
|
||||||
addLineToFile(
|
addLineToFile(
|
||||||
startupFile,
|
startupFile,
|
||||||
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`,
|
`. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -74,7 +76,7 @@ function getStartupFile() {
|
||||||
function getManualTeardownInstructions() {
|
function getManualTeardownInstructions() {
|
||||||
return [
|
return [
|
||||||
`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`,
|
`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`,
|
||||||
` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
|
` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`,
|
||||||
`Then restart your terminal or run: . $PROFILE`,
|
`Then restart your terminal or run: . $PROFILE`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +84,7 @@ function getManualTeardownInstructions() {
|
||||||
function getManualSetupInstructions() {
|
function getManualSetupInstructions() {
|
||||||
return [
|
return [
|
||||||
`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`,
|
`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`,
|
||||||
` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
|
` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`,
|
||||||
`Then restart your terminal or run: . $PROFILE`,
|
`Then restart your terminal or run: . $PROFILE`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ describe("PowerShell Core shell integration", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mock.module("../../config/safeChainDir.js", {
|
||||||
|
namedExports: {
|
||||||
|
getScriptsDir: () => "/test-home/.safe-chain/scripts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Mock child_process execSync
|
// Mock child_process execSync
|
||||||
mock.module("child_process", {
|
mock.module("child_process", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
|
|
@ -83,7 +89,7 @@ describe("PowerShell Core shell integration", () => {
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes(
|
content.includes(
|
||||||
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -93,7 +99,7 @@ describe("PowerShell Core shell integration", () => {
|
||||||
it("should remove init-pwsh.ps1 source line", () => {
|
it("should remove init-pwsh.ps1 source line", () => {
|
||||||
const initialContent = [
|
const initialContent = [
|
||||||
"# PowerShell profile",
|
"# PowerShell profile",
|
||||||
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
||||||
"Set-Alias ls Get-ChildItem",
|
"Set-Alias ls Get-ChildItem",
|
||||||
"Set-Alias grep Select-String",
|
"Set-Alias grep Select-String",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
@ -105,7 +111,7 @@ describe("PowerShell Core shell integration", () => {
|
||||||
|
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
|
!content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
|
||||||
);
|
);
|
||||||
assert.ok(content.includes("Set-Alias ls "));
|
assert.ok(content.includes("Set-Alias ls "));
|
||||||
assert.ok(content.includes("Set-Alias grep "));
|
assert.ok(content.includes("Set-Alias grep "));
|
||||||
|
|
@ -180,14 +186,14 @@ describe("PowerShell Core shell integration", () => {
|
||||||
await powershell.setup();
|
await powershell.setup();
|
||||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
|
content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Teardown
|
// Teardown
|
||||||
powershell.teardown(knownAikidoTools);
|
powershell.teardown(knownAikidoTools);
|
||||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
|
!content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -198,7 +204,7 @@ describe("PowerShell Core shell integration", () => {
|
||||||
|
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
const sourceMatches = (
|
const sourceMatches = (
|
||||||
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
|
content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) ||
|
||||||
[]
|
[]
|
||||||
).length;
|
).length;
|
||||||
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
|
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import {
|
||||||
removeLinesMatchingPattern,
|
removeLinesMatchingPattern,
|
||||||
validatePowerShellExecutionPolicy,
|
validatePowerShellExecutionPolicy,
|
||||||
} from "../helpers.js";
|
} from "../helpers.js";
|
||||||
|
import { getScriptsDir } from "../../config/safeChainDir.js";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const shellName = "Windows PowerShell";
|
const shellName = "Windows PowerShell";
|
||||||
const executableName = "powershell";
|
const executableName = "powershell";
|
||||||
|
|
@ -30,10 +32,10 @@ function teardown(tools) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the line that sources the safe-chain PowerShell initialization script
|
// Remove sourcing line to clean up safe-chain integration from the shell profile
|
||||||
removeLinesMatchingPattern(
|
removeLinesMatchingPattern(
|
||||||
startupFile,
|
startupFile,
|
||||||
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/,
|
/^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -52,7 +54,7 @@ async function setup() {
|
||||||
|
|
||||||
addLineToFile(
|
addLineToFile(
|
||||||
startupFile,
|
startupFile,
|
||||||
`. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`,
|
`. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -74,7 +76,7 @@ function getStartupFile() {
|
||||||
function getManualTeardownInstructions() {
|
function getManualTeardownInstructions() {
|
||||||
return [
|
return [
|
||||||
`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`,
|
`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`,
|
||||||
` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
|
` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`,
|
||||||
`Then restart your terminal or run: . $PROFILE`,
|
`Then restart your terminal or run: . $PROFILE`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +84,7 @@ function getManualTeardownInstructions() {
|
||||||
function getManualSetupInstructions() {
|
function getManualSetupInstructions() {
|
||||||
return [
|
return [
|
||||||
`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`,
|
`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`,
|
||||||
` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`,
|
` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`,
|
||||||
`Then restart your terminal or run: . $PROFILE`,
|
`Then restart your terminal or run: . $PROFILE`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ describe("Windows PowerShell shell integration", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mock.module("../../config/safeChainDir.js", {
|
||||||
|
namedExports: {
|
||||||
|
getScriptsDir: () => "/test-home/.safe-chain/scripts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Mock child_process execSync
|
// Mock child_process execSync
|
||||||
mock.module("child_process", {
|
mock.module("child_process", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
|
|
@ -83,7 +89,7 @@ describe("Windows PowerShell shell integration", () => {
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes(
|
content.includes(
|
||||||
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -93,7 +99,7 @@ describe("Windows PowerShell shell integration", () => {
|
||||||
it("should remove init-pwsh.ps1 source line", () => {
|
it("should remove init-pwsh.ps1 source line", () => {
|
||||||
const initialContent = [
|
const initialContent = [
|
||||||
"# Windows PowerShell profile",
|
"# Windows PowerShell profile",
|
||||||
'. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
'. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script',
|
||||||
"Set-Alias ls Get-ChildItem",
|
"Set-Alias ls Get-ChildItem",
|
||||||
"Set-Alias grep Select-String",
|
"Set-Alias grep Select-String",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
@ -105,7 +111,7 @@ describe("Windows PowerShell shell integration", () => {
|
||||||
|
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
|
!content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
|
||||||
);
|
);
|
||||||
assert.ok(content.includes("Set-Alias ls "));
|
assert.ok(content.includes("Set-Alias ls "));
|
||||||
assert.ok(content.includes("Set-Alias grep "));
|
assert.ok(content.includes("Set-Alias grep "));
|
||||||
|
|
@ -180,14 +186,14 @@ describe("Windows PowerShell shell integration", () => {
|
||||||
await windowsPowershell.setup();
|
await windowsPowershell.setup();
|
||||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
|
content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Teardown
|
// Teardown
|
||||||
windowsPowershell.teardown(knownAikidoTools);
|
windowsPowershell.teardown(knownAikidoTools);
|
||||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'),
|
!content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -198,7 +204,7 @@ describe("Windows PowerShell shell integration", () => {
|
||||||
|
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
const sourceMatches = (
|
const sourceMatches = (
|
||||||
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) ||
|
content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) ||
|
||||||
[]
|
[]
|
||||||
).length;
|
).length;
|
||||||
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
|
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import {
|
||||||
doesExecutableExistOnSystem,
|
doesExecutableExistOnSystem,
|
||||||
removeLinesMatchingPattern,
|
removeLinesMatchingPattern,
|
||||||
} from "../helpers.js";
|
} from "../helpers.js";
|
||||||
|
import { getScriptsDir } from "../../config/safeChainDir.js";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const shellName = "Zsh";
|
const shellName = "Zsh";
|
||||||
const executableName = "zsh";
|
const executableName = "zsh";
|
||||||
|
|
@ -31,10 +33,10 @@ function teardown(tools) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removes the line that sources the safe-chain zsh initialization script (~/.safe-chain/scripts/init-posix.sh)
|
// Remove sourcing line to complete shell integration cleanup
|
||||||
removeLinesMatchingPattern(
|
removeLinesMatchingPattern(
|
||||||
startupFile,
|
startupFile,
|
||||||
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/,
|
/^source\s+.*init-posix\.sh.*#\s*Safe-chain/,
|
||||||
eol
|
eol
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -46,7 +48,7 @@ function setup() {
|
||||||
|
|
||||||
addLineToFile(
|
addLineToFile(
|
||||||
startupFile,
|
startupFile,
|
||||||
`source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`,
|
`source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`,
|
||||||
eol
|
eol
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -69,7 +71,7 @@ function getStartupFile() {
|
||||||
function getManualTeardownInstructions() {
|
function getManualTeardownInstructions() {
|
||||||
return [
|
return [
|
||||||
`Remove the following line from your ~/.zshrc file:`,
|
`Remove the following line from your ~/.zshrc file:`,
|
||||||
` source ~/.safe-chain/scripts/init-posix.sh`,
|
` source ${path.join(getScriptsDir(), "init-posix.sh")}`,
|
||||||
`Then restart your terminal or run: source ~/.zshrc`,
|
`Then restart your terminal or run: source ~/.zshrc`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +79,7 @@ function getManualTeardownInstructions() {
|
||||||
function getManualSetupInstructions() {
|
function getManualSetupInstructions() {
|
||||||
return [
|
return [
|
||||||
`Add the following line to your ~/.zshrc file:`,
|
`Add the following line to your ~/.zshrc file:`,
|
||||||
` source ~/.safe-chain/scripts/init-posix.sh`,
|
` source ${path.join(getScriptsDir(), "init-posix.sh")}`,
|
||||||
`Then restart your terminal or run: source ~/.zshrc`,
|
`Then restart your terminal or run: source ~/.zshrc`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ describe("Zsh shell integration", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mock.module("../../config/safeChainDir.js", {
|
||||||
|
namedExports: {
|
||||||
|
getScriptsDir: () => "/test-home/.safe-chain/scripts",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Mock child_process execSync
|
// Mock child_process execSync
|
||||||
mock.module("child_process", {
|
mock.module("child_process", {
|
||||||
namedExports: {
|
namedExports: {
|
||||||
|
|
@ -73,7 +79,7 @@ describe("Zsh shell integration", () => {
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
content.includes(
|
content.includes(
|
||||||
"source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script"
|
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -83,7 +89,7 @@ describe("Zsh shell integration", () => {
|
||||||
assert.strictEqual(result, true);
|
assert.strictEqual(result, true);
|
||||||
|
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
|
assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -114,7 +120,7 @@ describe("Zsh shell integration", () => {
|
||||||
it("should remove zsh initialization script source line", () => {
|
it("should remove zsh initialization script source line", () => {
|
||||||
const initialContent = [
|
const initialContent = [
|
||||||
"#!/bin/zsh",
|
"#!/bin/zsh",
|
||||||
"source ~/.safe-chain/scripts/init-posix.sh",
|
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script",
|
||||||
"alias ls='ls --color=auto'",
|
"alias ls='ls --color=auto'",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
|
@ -125,7 +131,7 @@ describe("Zsh shell integration", () => {
|
||||||
|
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script")
|
||||||
);
|
);
|
||||||
assert.ok(content.includes("alias ls="));
|
assert.ok(content.includes("alias ls="));
|
||||||
});
|
});
|
||||||
|
|
@ -180,13 +186,13 @@ describe("Zsh shell integration", () => {
|
||||||
// Setup
|
// Setup
|
||||||
zsh.setup();
|
zsh.setup();
|
||||||
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
let content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh"));
|
assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh"));
|
||||||
|
|
||||||
// Teardown
|
// Teardown
|
||||||
zsh.teardown(tools);
|
zsh.teardown(tools);
|
||||||
content = fs.readFileSync(mockStartupFile, "utf-8");
|
content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -207,7 +213,7 @@ describe("Zsh shell integration", () => {
|
||||||
const initialContent = [
|
const initialContent = [
|
||||||
"#!/bin/zsh",
|
"#!/bin/zsh",
|
||||||
"alias npm='old-npm'",
|
"alias npm='old-npm'",
|
||||||
"source ~/.safe-chain/scripts/init-posix.sh",
|
"source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script",
|
||||||
"alias ls='ls --color=auto'",
|
"alias ls='ls --color=auto'",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
|
@ -218,7 +224,7 @@ describe("Zsh shell integration", () => {
|
||||||
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
const content = fs.readFileSync(mockStartupFile, "utf-8");
|
||||||
assert.ok(!content.includes("alias npm="));
|
assert.ok(!content.includes("alias npm="));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!content.includes("source ~/.safe-chain/scripts/init-posix.sh")
|
!content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script")
|
||||||
);
|
);
|
||||||
assert.ok(content.includes("alias ls="));
|
assert.ok(content.includes("alias ls="));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
import { detectShells } from "./shellDetection.js";
|
import { detectShells } from "./shellDetection.js";
|
||||||
import { knownAikidoTools, getPackageManagerList, getShimsDir, getScriptsDir } from "./helpers.js";
|
import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
|
||||||
|
import { getShimsDir, getScriptsDir } from "../config/safeChainDir.js";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -109,4 +110,5 @@ export async function teardownDirectories() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { platform } from 'os';
|
|
||||||
import { ui } from "../environment/userInteraction.js";
|
|
||||||
import { readFileSync, existsSync } from "node:fs";
|
|
||||||
import {randomUUID} from "node:crypto";
|
|
||||||
import {createWriteStream} from "fs";
|
|
||||||
import archiver from 'archiver';
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
export async function printUltimateLogs() {
|
|
||||||
const { proxyLogPath, ultimateLogPath, proxyErrLogPath, ultimateErrLogPath } = getPathsPerPlatform();
|
|
||||||
|
|
||||||
await printLogs(
|
|
||||||
"SafeChain Proxy",
|
|
||||||
proxyLogPath,
|
|
||||||
proxyErrLogPath
|
|
||||||
);
|
|
||||||
|
|
||||||
await printLogs(
|
|
||||||
"SafeChain Ultimate",
|
|
||||||
ultimateLogPath,
|
|
||||||
ultimateErrLogPath
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function troubleshootingExport() {
|
|
||||||
const { logDir } = getPathsPerPlatform();
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (!existsSync(logDir)) {
|
|
||||||
ui.writeError(`Log directory not found: ${logDir}`);
|
|
||||||
reject(new Error(`Log directory not found: ${logDir}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0];
|
|
||||||
const uuid = randomUUID();
|
|
||||||
const zipFileName = `safechain-ultimate-${date}-${uuid}.zip`;
|
|
||||||
const output = createWriteStream(zipFileName);
|
|
||||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
||||||
|
|
||||||
output.on('close', () => {
|
|
||||||
ui.writeInformation(`Logs collected and zipped as: ${path.resolve(zipFileName)}`);
|
|
||||||
resolve(zipFileName);
|
|
||||||
});
|
|
||||||
|
|
||||||
archive.on('error', (/** @type {Error} */ err) => {
|
|
||||||
ui.writeError(`Failed to zip logs: ${err.message}`);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
archive.pipe(output);
|
|
||||||
archive.directory(logDir, false);
|
|
||||||
archive.finalize();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function getPathsPerPlatform() {
|
|
||||||
const os = platform();
|
|
||||||
if (os === 'win32') {
|
|
||||||
const logDir = `C:\\ProgramData\\AikidoSecurity\\SafeChainUltimate\\logs`;
|
|
||||||
return {
|
|
||||||
logDir,
|
|
||||||
proxyLogPath: `${logDir}\\SafeChainProxy.log`,
|
|
||||||
ultimateLogPath: `${logDir}\\SafeChainUltimate.log`,
|
|
||||||
proxyErrLogPath: `${logDir}\\SafeChainProxy.err`,
|
|
||||||
ultimateErrLogPath: `${logDir}\\SafeChainUltimate.err`,
|
|
||||||
};
|
|
||||||
} else if (os === 'darwin') {
|
|
||||||
const logDir = `/Library/Logs/AikidoSecurity/SafeChainUltimate`;
|
|
||||||
return {
|
|
||||||
logDir,
|
|
||||||
proxyLogPath: `${logDir}/safechain-proxy.log`,
|
|
||||||
ultimateLogPath: `${logDir}/safechain-ultimate.log`,
|
|
||||||
proxyErrLogPath: `${logDir}/safechain-proxy.error.log`,
|
|
||||||
ultimateErrLogPath: `${logDir}/safechain-ultimate.error.log`,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error('Unsupported platform for log printing.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} appName
|
|
||||||
* @param {string} logPath
|
|
||||||
* @param {string} errLogPath
|
|
||||||
*/
|
|
||||||
async function printLogs(appName, logPath, errLogPath) {
|
|
||||||
ui.writeInformation(`=== ${appName} Logs ===`);
|
|
||||||
try {
|
|
||||||
if (existsSync(logPath)) {
|
|
||||||
const logs = readFileSync(logPath, "utf-8");
|
|
||||||
ui.writeInformation(logs);
|
|
||||||
} else {
|
|
||||||
ui.writeWarning(`${appName} log file not found: ${logPath}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
ui.writeError(`Failed to read ${appName} logs: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.writeInformation(`=== ${appName} Error Logs ===`);
|
|
||||||
try {
|
|
||||||
if (existsSync(errLogPath)) {
|
|
||||||
const errLogs = readFileSync(errLogPath, "utf-8");
|
|
||||||
ui.writeInformation(errLogs);
|
|
||||||
} else {
|
|
||||||
ui.writeInformation(`No error log file found for ${appName}.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
ui.writeError(`Failed to read ${appName} error logs: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -58,12 +58,21 @@ export class DockerTestContainer {
|
||||||
`docker run -d --name ${this.containerName} ${imageName} sleep infinity`,
|
`docker run -d --name ${this.containerName} ${imageName} sleep infinity`,
|
||||||
{ stdio: "ignore" }
|
{ stdio: "ignore" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.startMalwareMirror();
|
||||||
|
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to start container: ${error.message}`);
|
throw new Error(`Failed to start container: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async startMalwareMirror() {
|
||||||
|
const shell = await this.openShell("zsh");
|
||||||
|
await shell.runCommand("node /utils/malwarelistmirror.mjs &");
|
||||||
|
await shell.runCommand("until curl -sf http://127.0.0.1:5555/ready; do sleep 0.2; done");
|
||||||
|
}
|
||||||
|
|
||||||
dockerExec(command, daemon = false) {
|
dockerExec(command, daemon = false) {
|
||||||
if (!this.isRunning) {
|
if (!this.isRunning) {
|
||||||
throw new Error("Container is not running");
|
throw new Error("Container is not running");
|
||||||
|
|
@ -84,10 +93,14 @@ export class DockerTestContainer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async openShell(shell) {
|
async openShell(shell, { user } = {}) {
|
||||||
|
const execArgs = user
|
||||||
|
? ["exec", "-it", "-u", user, this.containerName, shell]
|
||||||
|
: ["exec", "-it", this.containerName, shell];
|
||||||
|
|
||||||
let ptyProcess = pty.spawn(
|
let ptyProcess = pty.spawn(
|
||||||
"docker",
|
"docker",
|
||||||
["exec", "-it", this.containerName, shell],
|
execArgs,
|
||||||
{
|
{
|
||||||
name: "xterm-color",
|
name: "xterm-color",
|
||||||
cols: 80,
|
cols: 80,
|
||||||
|
|
@ -121,7 +134,7 @@ export class DockerTestContainer {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
// Fallback in case the command doesn't finish in a reasonable time
|
// Fallback in case the command doesn't finish in a reasonable time
|
||||||
// oxlint-disable-next-line no-console - having this log in CI helps diagnose issues
|
// oxlint-disable-next-line no-console - having this log in CI helps diagnose issues
|
||||||
console.log("Command timeout reached");
|
console.log(`Command timeout reached for "${command}"`);
|
||||||
resolve({ allData, output: parseShellOutput(allData), command });
|
resolve({ allData, output: parseShellOutput(allData), command });
|
||||||
ptyProcess.removeListener("data", handleInput);
|
ptyProcess.removeListener("data", handleInput);
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ ARG NODE_VERSION=latest
|
||||||
ARG NPM_VERSION=latest
|
ARG NPM_VERSION=latest
|
||||||
ARG YARN_VERSION=latest
|
ARG YARN_VERSION=latest
|
||||||
ARG PNPM_VERSION=latest
|
ARG PNPM_VERSION=latest
|
||||||
|
ARG RUSH_VERSION=latest
|
||||||
ARG PYTHON_VERSION=3
|
ARG PYTHON_VERSION=3
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-c"]
|
SHELL ["/bin/bash", "-c"]
|
||||||
|
|
@ -46,6 +47,7 @@ RUN volta install node@${NODE_VERSION}
|
||||||
RUN volta install npm@${NPM_VERSION}
|
RUN volta install npm@${NPM_VERSION}
|
||||||
RUN volta install yarn@${YARN_VERSION}
|
RUN volta install yarn@${YARN_VERSION}
|
||||||
RUN volta install pnpm@${PNPM_VERSION}
|
RUN volta install pnpm@${PNPM_VERSION}
|
||||||
|
RUN volta install @microsoft/rush@${RUSH_VERSION}
|
||||||
|
|
||||||
# Install Bun
|
# Install Bun
|
||||||
RUN curl -fsSL https://bun.sh/install | bash
|
RUN curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
@ -77,6 +79,10 @@ RUN apt-get update && apt-get install -y pipx && \
|
||||||
pipx install poetry && \
|
pipx install poetry && \
|
||||||
ln -sf /root/.local/bin/poetry /usr/local/bin/poetry
|
ln -sf /root/.local/bin/poetry /usr/local/bin/poetry
|
||||||
|
|
||||||
|
# Install PDM
|
||||||
|
RUN pipx install pdm && \
|
||||||
|
ln -sf /root/.local/bin/pdm /usr/local/bin/pdm
|
||||||
|
|
||||||
# Copy and install Safe chain
|
# Copy and install Safe chain
|
||||||
COPY --from=builder /app/*.tgz /pkgs/
|
COPY --from=builder /app/*.tgz /pkgs/
|
||||||
RUN npm install -g /pkgs/*.tgz
|
RUN npm install -g /pkgs/*.tgz
|
||||||
|
|
@ -84,3 +90,5 @@ RUN npm install -g /pkgs/*.tgz
|
||||||
WORKDIR /testapp
|
WORKDIR /testapp
|
||||||
RUN npm init -y
|
RUN npm init -y
|
||||||
|
|
||||||
|
COPY test/e2e/utils/malwarelistmirror.mjs /utils/malwarelistmirror.mjs
|
||||||
|
ENV SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:5555
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,9 @@ describe("E2E: bun coverage", () => {
|
||||||
|
|
||||||
var result = await shell.runCommand("bun install");
|
var result = await shell.runCommand("bun install");
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -65,8 +66,9 @@ describe("E2E: bun coverage", () => {
|
||||||
|
|
||||||
const result = await shell.runCommand("bunx safe-chain-test");
|
const result = await shell.runCommand("bunx safe-chain-test");
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,9 @@ describe("E2E: npm coverage", () => {
|
||||||
|
|
||||||
var result = await shell.runCommand("npm install");
|
var result = await shell.runCommand("npm install");
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
|
||||||
300
test/e2e/pdm.e2e.spec.js
Normal file
300
test/e2e/pdm.e2e.spec.js
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("E2E: pdm coverage", () => {
|
||||||
|
let container;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
DockerTestContainer.buildImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Run a new Docker container for each test
|
||||||
|
container = new DockerTestContainer();
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
const installationShell = await container.openShell("zsh");
|
||||||
|
await installationShell.runCommand("safe-chain setup");
|
||||||
|
|
||||||
|
// Clear pdm cache
|
||||||
|
await installationShell.runCommand("command pdm cache clear");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Stop and clean up the container after each test
|
||||||
|
if (container) {
|
||||||
|
await container.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`successfully installs known safe packages with pdm add`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
// Initialize a new pdm project
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-project && cd /tmp/test-pdm-project");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-project && pdm init --non-interactive");
|
||||||
|
|
||||||
|
// Add a safe package
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-project && pdm add requests"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`pdm add with specific version`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-version && cd /tmp/test-pdm-version");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-version && pdm init --non-interactive");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-version && pdm add requests==2.32.3"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks installation of malicious Python packages via pdm`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-malware && cd /tmp/test-pdm-malware");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-malware && pdm init --non-interactive");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-malware && pdm add numpy==2.4.4"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
|
||||||
|
`Expected malware to be blocked. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Expected exit message. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`pdm install installs dependencies from pyproject.toml`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-install && cd /tmp/test-pdm-install");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-install && pdm init --non-interactive");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-install && pdm add requests");
|
||||||
|
|
||||||
|
// Now remove the virtualenv and run install
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-install && rm -rf .venv");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-install && pdm install"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`pdm update with specific packages`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-update-specific && cd /tmp/test-pdm-update-specific");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm init --non-interactive");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-update-specific && pdm add requests certifi");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-update-specific && pdm update requests"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || result.output.includes("Updating"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`pdm add with multiple packages`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-multi && cd /tmp/test-pdm-multi");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-multi && pdm init --non-interactive");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-multi && pdm add requests certifi"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`pdm add with extras`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-extras && cd /tmp/test-pdm-extras");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-extras && pdm init --non-interactive");
|
||||||
|
|
||||||
|
// Use quotes to prevent shell expansion of square brackets
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
'cd /tmp/test-pdm-extras && pdm add "requests[security]"'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`pdm add with development group`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-dev && cd /tmp/test-pdm-dev");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-dev && pdm init --non-interactive");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-dev && pdm add -dG dev pytest"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || result.output.includes("Installing"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`pdm lock creates/updates lock file`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-lock && cd /tmp/test-pdm-lock");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-lock && pdm init --non-interactive");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-lock && pdm add requests");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-lock && rm pdm.lock");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-lock && pdm lock"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || result.output.includes("Resolving") || result.output.includes("lock file"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`pdm remove does not download packages`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-remove && cd /tmp/test-pdm-remove");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-remove && pdm init --non-interactive");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-remove && pdm add requests");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-remove && pdm remove requests"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove should succeed - it doesn't download packages, just modifies pyproject.toml
|
||||||
|
assert.ok(
|
||||||
|
!result.output.includes("blocked"),
|
||||||
|
`Remove command should not trigger downloads. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`blocks malware during pdm install`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
// Create a project with malware in dependencies
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-install-malware && cd /tmp/test-pdm-install-malware");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-install-malware && pdm init --non-interactive");
|
||||||
|
|
||||||
|
// Add malware package - this will create lock file and attempt download
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-install-malware && pdm add numpy==2.4.4 2>&1"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
|
||||||
|
`Expected malware to be blocked during add (which triggers install). Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Expected exit message. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`blocks malware when adding malicious dependency alongside safe one`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-batch && cd /tmp/test-pdm-batch");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-batch && pdm init --non-interactive");
|
||||||
|
|
||||||
|
// Try to add malware alongside safe package
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /tmp/test-pdm-batch && pdm add numpy==2.4.4 requests 2>&1"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked") && result.output.includes("malicious package downloads"),
|
||||||
|
`Expected malware to be blocked. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Expected exit message. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify safe package was also not installed due to malware in batch
|
||||||
|
const listResult = await shell.runCommand("cd /tmp/test-pdm-batch && pdm list");
|
||||||
|
assert.ok(
|
||||||
|
!listResult.output.includes("requests"),
|
||||||
|
`Safe package should not be installed when batch includes malware. Output was:\n${listResult.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`pdm non-network commands work correctly`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
await shell.runCommand("mkdir /tmp/test-pdm-nonnetwork && cd /tmp/test-pdm-nonnetwork");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm init --non-interactive");
|
||||||
|
await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm add requests");
|
||||||
|
|
||||||
|
// Test pdm --version
|
||||||
|
const versionResult = await shell.runCommand("pdm --version");
|
||||||
|
assert.ok(
|
||||||
|
versionResult.output.includes("PDM") || versionResult.output.includes("pdm"),
|
||||||
|
`Expected version output. Output was:\n${versionResult.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test pdm list (list installed packages)
|
||||||
|
const listResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm list");
|
||||||
|
assert.ok(
|
||||||
|
listResult.output.includes("requests"),
|
||||||
|
`Expected to see installed package. Output was:\n${listResult.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test pdm info (show project info)
|
||||||
|
const infoResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm info");
|
||||||
|
assert.ok(
|
||||||
|
infoResult.output.includes("PDM") || infoResult.output.includes("Python") || infoResult.output.includes("Project"),
|
||||||
|
`Expected project info. Output was:\n${infoResult.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test pdm config (show configuration)
|
||||||
|
const configResult = await shell.runCommand("pdm config");
|
||||||
|
assert.ok(
|
||||||
|
configResult.output.length > 0,
|
||||||
|
`Expected configuration output. Output was:\n${configResult.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test pdm run (execute command in virtualenv) - non-network command
|
||||||
|
const runResult = await shell.runCommand("cd /tmp/test-pdm-nonnetwork && pdm run python --version");
|
||||||
|
assert.ok(
|
||||||
|
runResult.output.includes("Python"),
|
||||||
|
`Expected Python version output. Output was:\n${runResult.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
168
test/e2e/pip-minimum-age.e2e.spec.js
Normal file
168
test/e2e/pip-minimum-age.e2e.spec.js
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
|
|
||||||
|
describe("E2E: pip minimum package age", () => {
|
||||||
|
let container;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
DockerTestContainer.buildImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
container = new DockerTestContainer();
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
const installationShell = await container.openShell("zsh");
|
||||||
|
await installationShell.runCommand("safe-chain setup");
|
||||||
|
await installationShell.runCommand("pip3 cache purge");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (container) {
|
||||||
|
await container.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to an older PyPI version for flexible constraints", async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const latestVersion = await getLatestPackageVersion(shell, "openai");
|
||||||
|
const tooYoungTimestamps = getTooYoungReleaseTimestamps();
|
||||||
|
|
||||||
|
await startFeedServer(container, [
|
||||||
|
{
|
||||||
|
source: "pypi",
|
||||||
|
package_name: "openai",
|
||||||
|
version: latestVersion,
|
||||||
|
...tooYoungTimestamps,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const installResult = await shell.runCommand(
|
||||||
|
'SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:8123 pip3 install --break-system-packages "openai>=2.8.0,<3" --safe-chain-logging=verbose'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
installResult.output.includes(`openai@${latestVersion} is newer than 48 hours and was removed`),
|
||||||
|
`Expected Safe Chain to suppress the latest openai version. Output was:\n${installResult.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!installResult.output.includes("blocked by safe-chain direct download minimum package age"),
|
||||||
|
`Expected fallback during resolution, not a direct-download block. Output was:\n${installResult.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
installResult.output.includes("Successfully installed"),
|
||||||
|
`Expected pip install to succeed after fallback. Output was:\n${installResult.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const installedVersion = await getInstalledVersion(shell, "openai");
|
||||||
|
assert.notEqual(
|
||||||
|
installedVersion,
|
||||||
|
latestVersion,
|
||||||
|
`Expected fallback to an older openai version, but installed ${latestVersion}.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails cleanly for exact pinned too-young PyPI versions", async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const latestVersion = await getLatestPackageVersion(shell, "openai");
|
||||||
|
const tooYoungTimestamps = getTooYoungReleaseTimestamps();
|
||||||
|
|
||||||
|
await startFeedServer(container, [
|
||||||
|
{
|
||||||
|
source: "pypi",
|
||||||
|
package_name: "openai",
|
||||||
|
version: latestVersion,
|
||||||
|
...tooYoungTimestamps,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const installResult = await shell.runCommand(
|
||||||
|
`SAFE_CHAIN_MALWARE_LIST_BASE_URL=http://127.0.0.1:8123 pip3 install --break-system-packages openai==${latestVersion} --safe-chain-logging=verbose`
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
installResult.output.includes(`openai@${latestVersion} is newer than 48 hours and was removed`),
|
||||||
|
`Expected Safe Chain to suppress the pinned openai version. Output was:\n${installResult.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
installResult.output.includes(`No matching distribution found for openai==${latestVersion}`) ||
|
||||||
|
installResult.output.includes(`Could not find a version that satisfies the requirement openai==${latestVersion}`),
|
||||||
|
`Expected pip to fail because the exact version was suppressed. Output was:\n${installResult.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!installResult.output.includes("blocked by safe-chain direct download minimum package age"),
|
||||||
|
`Expected resolver failure for an exact pin, not a direct-download block. Output was:\n${installResult.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getLatestPackageVersion(shell, packageName) {
|
||||||
|
const result = await shell.runCommand(`/usr/bin/pip3 index versions ${packageName}`);
|
||||||
|
const version = result.output.match(new RegExp(`${packageName} \\(([^)]+)\\)`))?.[1];
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
version,
|
||||||
|
`Could not determine latest ${packageName} version from pip output:\n${result.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInstalledVersion(shell, packageName) {
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
`python3 - <<'PY'
|
||||||
|
import importlib.metadata
|
||||||
|
print(importlib.metadata.version("${packageName}"))
|
||||||
|
PY`
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.output.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFeedServer(container, releases) {
|
||||||
|
const shell = await container.openShell("bash");
|
||||||
|
const releasesJson = JSON.stringify(releases, null, 2);
|
||||||
|
|
||||||
|
await shell.runCommand(`mkdir -p /tmp/safe-chain-feed/releases
|
||||||
|
cat > /tmp/safe-chain-feed/malware_pypi.json <<'EOF'
|
||||||
|
[]
|
||||||
|
EOF
|
||||||
|
cat > /tmp/safe-chain-feed/releases/pypi.json <<'EOF'
|
||||||
|
${releasesJson}
|
||||||
|
EOF`);
|
||||||
|
|
||||||
|
container.dockerExec(
|
||||||
|
"nohup python3 -m http.server 8123 -d /tmp/safe-chain-feed >/tmp/safe-chain-feed.log 2>&1 </dev/null &",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
const readinessResult = await shell.runCommand(`i=0
|
||||||
|
while [ "$i" -lt 100 ]; do
|
||||||
|
if curl -fsS http://127.0.0.1:8123/releases/pypi.json >/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
if [ "$i" -ge 100 ]; then
|
||||||
|
echo "feed server did not become ready" >&2
|
||||||
|
cat /tmp/safe-chain-feed.log >&2 || true
|
||||||
|
fi`);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
readinessResult.output.includes("feed server did not become ready"),
|
||||||
|
false,
|
||||||
|
`Expected local feed server to become ready. Output was:\n${readinessResult.output}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTooYoungReleaseTimestamps() {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
released_on: now,
|
||||||
|
scraped_on: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -128,15 +128,16 @@ describe("E2E: pip coverage", () => {
|
||||||
it(`safe-chain blocks installation of malicious Python packages`, async () => {
|
it(`safe-chain blocks installation of malicious Python packages`, async () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"pip3 install --break-system-packages safe-chain-pi-test"
|
"pip3 install --break-system-packages numpy==2.4.4 --safe-chain-logging=verbose"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads:"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads:/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("safe_chain_pi_test@0.0.1"),
|
result.output.includes("numpy@2.4.4"),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -146,7 +147,7 @@ describe("E2E: pip coverage", () => {
|
||||||
|
|
||||||
const listResult = await shell.runCommand("pip3 list");
|
const listResult = await shell.runCommand("pip3 list");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!listResult.output.includes("safe-chain-pi-test"),
|
!listResult.output.includes("numpy"),
|
||||||
`Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}`
|
`Malicious package was installed despite safe-chain protection. Output of 'pip3 list' was:\n${listResult.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ describe("E2E: pipx coverage", () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"pipx install safe-chain-pi-test"
|
"pipx install numpy==2.4.4"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -86,7 +86,7 @@ describe("E2E: pipx coverage", () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"pipx run safe-chain-pi-test --version"
|
"pipx run numpy==2.4.4 --version"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -122,7 +122,7 @@ describe("E2E: pipx coverage", () => {
|
||||||
await shell.runCommand("pipx install ruff");
|
await shell.runCommand("pipx install ruff");
|
||||||
|
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"pipx runpip ruff install safe-chain-pi-test"
|
"pipx runpip ruff install numpy==2.4.4"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -185,7 +185,7 @@ describe("E2E: pipx coverage", () => {
|
||||||
|
|
||||||
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
|
await shell.runCommand("pipx install ruff --safe-chain-logging=verbose");
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"pipx inject ruff safe-chain-pi-test --safe-chain-logging=verbose"
|
"pipx inject ruff numpy==2.4.4 --safe-chain-logging=verbose"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
|
||||||
|
|
@ -70,8 +70,9 @@ describe("E2E: pnpm coverage", () => {
|
||||||
|
|
||||||
var result = await shell.runCommand("pnpm install");
|
var result = await shell.runCommand("pnpm install");
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ describe("E2E: poetry coverage", () => {
|
||||||
await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction");
|
await shell.runCommand("cd /tmp/test-poetry-malware && poetry init --no-interaction");
|
||||||
|
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"cd /tmp/test-poetry-malware && poetry add safe-chain-pi-test"
|
"cd /tmp/test-poetry-malware && poetry add numpy==2.4.4"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -300,7 +300,7 @@ describe("E2E: poetry coverage", () => {
|
||||||
|
|
||||||
// Add malware package - this will create lock file and attempt download
|
// Add malware package - this will create lock file and attempt download
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"cd /tmp/test-poetry-install-malware && poetry add safe-chain-pi-test 2>&1"
|
"cd /tmp/test-poetry-install-malware && poetry add numpy==2.4.4 2>&1"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -324,7 +324,7 @@ describe("E2E: poetry coverage", () => {
|
||||||
|
|
||||||
// Now try to add malware via add command
|
// Now try to add malware via add command
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"cd /tmp/test-poetry-update-add && poetry add safe-chain-pi-test 2>&1"
|
"cd /tmp/test-poetry-update-add && poetry add numpy==2.4.4 2>&1"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -345,7 +345,7 @@ describe("E2E: poetry coverage", () => {
|
||||||
|
|
||||||
// Try to add malware directly - this is the primary vector
|
// Try to add malware directly - this is the primary vector
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"cd /tmp/test-poetry-req-malware && poetry add safe-chain-pi-test requests 2>&1"
|
"cd /tmp/test-poetry-req-malware && poetry add numpy==2.4.4 requests 2>&1"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
|
||||||
148
test/e2e/rush.e2e.spec.js
Normal file
148
test/e2e/rush.e2e.spec.js
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
|
import {
|
||||||
|
buildRushConfig,
|
||||||
|
resolveRushVersions,
|
||||||
|
writeTextFile,
|
||||||
|
} from "./utils/rushtestutils.mjs";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
// These tests cover safe-chain's Rush wrapper: pre-scanning `rush add` and
|
||||||
|
// blocking malicious packages downloaded during `rush update` via the MITM
|
||||||
|
// proxy. They use a single Rush-internal package manager (pnpm) — see
|
||||||
|
// `utils/rushtestutils.mjs` for why this suite isn't parameterised over the
|
||||||
|
// CI matrix's NPM_VERSION/PNPM_VERSION/YARN_VERSION values.
|
||||||
|
|
||||||
|
describe("E2E: rush coverage", () => {
|
||||||
|
let container;
|
||||||
|
/** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */
|
||||||
|
let resolvedVersions;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
DockerTestContainer.buildImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
container = new DockerTestContainer();
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
const installationShell = await container.openShell("zsh");
|
||||||
|
await installationShell.runCommand("safe-chain setup");
|
||||||
|
|
||||||
|
if (!resolvedVersions) {
|
||||||
|
resolvedVersions = await resolveRushVersions(installationShell);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupRushWorkspace(installationShell, { resolvedVersions });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (container) {
|
||||||
|
await container.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safe-chain successfully adds safe packages", async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /testapp/apps/test-app && rush add --package axios@1.13.0 --exact --skip-update --safe-chain-logging=verbose"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safe-chain blocks rush add of malicious packages", async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /testapp/apps/test-app && rush add --package safe-chain-test --skip-update"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Malicious changes detected:"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const packageJson = await shell.runCommand(
|
||||||
|
"cat /testapp/apps/test-app/package.json"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
!packageJson.output.includes("safe-chain-test"),
|
||||||
|
`Malicious package was added despite safe-chain protection. Output was:\n${packageJson.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safe-chain proxy blocks malicious package downloads during rush update", async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await setupRushWorkspace(shell, {
|
||||||
|
resolvedVersions,
|
||||||
|
packageJson: `{
|
||||||
|
"name": "test-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-chain-test": "0.0.1-security"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// `--safe-chain-skip-minimum-package-age` is needed because Rush's
|
||||||
|
// internal pnpm bootstrap (`npm install pnpm@<resolvedVersion>`) goes
|
||||||
|
// through the safe-chain proxy. When the CI matrix selects pnpm
|
||||||
|
// `latest`, the just-released version can be below the minimum age
|
||||||
|
// threshold and Rush's install would otherwise be blocked before our
|
||||||
|
// malicious-download assertion is reached.
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /testapp/apps/test-app && rush update --safe-chain-skip-minimum-package-age"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads/,
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupRushWorkspace(shell, { resolvedVersions, packageJson }) {
|
||||||
|
const rushConfig = buildRushConfig({
|
||||||
|
rushVersion: resolvedVersions.rushVersion,
|
||||||
|
pnpmVersion: resolvedVersions.pnpmVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
await shell.runCommand("rm -rf /testapp/common /testapp/apps/test-app");
|
||||||
|
await shell.runCommand("mkdir -p /testapp/apps/test-app");
|
||||||
|
await writeTextFile(
|
||||||
|
shell,
|
||||||
|
"/testapp/rush.json",
|
||||||
|
JSON.stringify(rushConfig, null, 2)
|
||||||
|
);
|
||||||
|
await writeTextFile(
|
||||||
|
shell,
|
||||||
|
"/testapp/apps/test-app/package.json",
|
||||||
|
packageJson ??
|
||||||
|
`{
|
||||||
|
"name": "test-app",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
100
test/e2e/rushx.e2e.spec.js
Normal file
100
test/e2e/rushx.e2e.spec.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
|
import {
|
||||||
|
buildRushConfig,
|
||||||
|
resolveRushVersions,
|
||||||
|
writeTextFile,
|
||||||
|
} from "./utils/rushtestutils.mjs";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("E2E: rushx coverage", () => {
|
||||||
|
let container;
|
||||||
|
/** @type {{ rushVersion: string, pnpmVersion: string } | undefined} */
|
||||||
|
let resolvedVersions;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
DockerTestContainer.buildImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
container = new DockerTestContainer();
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
const installationShell = await container.openShell("zsh");
|
||||||
|
await installationShell.runCommand("safe-chain setup");
|
||||||
|
|
||||||
|
if (!resolvedVersions) {
|
||||||
|
resolvedVersions = await resolveRushVersions(installationShell);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupRushWorkspace(installationShell, { resolvedVersions });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (container) {
|
||||||
|
await container.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safe-chain successfully scans safe package downloads from rushx scripts", async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /testapp/apps/test-app && rushx install-safe --safe-chain-logging=verbose"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safe-chain blocks malicious package downloads from rushx scripts", async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"cd /testapp/apps/test-app && rushx install-malicious"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads/,
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupRushWorkspace(shell, { resolvedVersions }) {
|
||||||
|
const rushConfig = buildRushConfig({
|
||||||
|
rushVersion: resolvedVersions.rushVersion,
|
||||||
|
pnpmVersion: resolvedVersions.pnpmVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
await shell.runCommand(
|
||||||
|
"mkdir -p /testapp/common/config/rush /testapp/apps/test-app"
|
||||||
|
);
|
||||||
|
await writeTextFile(
|
||||||
|
shell,
|
||||||
|
"/testapp/rush.json",
|
||||||
|
JSON.stringify(rushConfig, null, 2)
|
||||||
|
);
|
||||||
|
await writeTextFile(
|
||||||
|
shell,
|
||||||
|
"/testapp/apps/test-app/package.json",
|
||||||
|
`{
|
||||||
|
"name": "test-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"install-safe": "npm install axios@1.13.0",
|
||||||
|
"install-malicious": "npm install safe-chain-test@0.0.1-security"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -97,11 +97,12 @@ describe("E2E: safe-chain CLI python/pip support", () => {
|
||||||
await shell.runCommand("pip3 cache purge");
|
await shell.runCommand("pip3 cache purge");
|
||||||
|
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"safe-chain pip3 install --break-system-packages safe-chain-pi-test"
|
"safe-chain pip3 install --break-system-packages numpy==2.4.4"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads/,
|
||||||
`Should have blocked malware. Output was:\n${result.output}`
|
`Should have blocked malware. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
79
test/e2e/utils/malwarelistmirror.mjs
Normal file
79
test/e2e/utils/malwarelistmirror.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
// Test-only mirror of the malware list. Injects known-safe packages as malicious
|
||||||
|
// to simulate blocking behavior in e2e tests without affecting real data.
|
||||||
|
|
||||||
|
import * as http from "node:http";
|
||||||
|
|
||||||
|
const lists = await downloadLists();
|
||||||
|
const server = http.createServer(handleRequest);
|
||||||
|
server.listen(5555, "127.0.0.1");
|
||||||
|
console.log("listening on http://127.0.0.1:5555");
|
||||||
|
|
||||||
|
function handleRequest(req, res) {
|
||||||
|
if (req.method !== "GET" || !req.url) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url.startsWith("/ready")) {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
if (req.url.startsWith(list.path)) {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(list.data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadLists() {
|
||||||
|
const lists = [
|
||||||
|
{
|
||||||
|
"path": "/malware_predictions.json",
|
||||||
|
"patchFunc": (data) => data,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/malware_pypi.json",
|
||||||
|
"patchFunc": patchPypi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/releases/npm.json",
|
||||||
|
"patchFunc": (data) => data,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/releases/pypi.json",
|
||||||
|
"patchFunc": (data) => data,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const list of lists) {
|
||||||
|
list.data = list.patchFunc(await downloadList(list.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lists;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadList(path) {
|
||||||
|
const baseUrl = "https://malware-list.aikido.dev";
|
||||||
|
const url = `${baseUrl}${path}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPypi(data) {
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
"package_name": "numpy",
|
||||||
|
"version": "2.4.4",
|
||||||
|
"reason": "MALWARE"
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
69
test/e2e/utils/rushtestutils.mjs
Normal file
69
test/e2e/utils/rushtestutils.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// Helpers for the Rush E2E suites.
|
||||||
|
//
|
||||||
|
// What these suites actually test: that safe-chain's shim intercepts `rush`
|
||||||
|
// and `rushx` invocations correctly. The contents of `rush.json` are just
|
||||||
|
// fixture noise needed to make Rush run at all — Rush's schema requires
|
||||||
|
// exact semver for `rushVersion`/`pnpmVersion` and refuses dist-tags like
|
||||||
|
// "latest", so we read both back from the binaries baked into the image.
|
||||||
|
//
|
||||||
|
// * `rushVersion` ← `rush --version` (image installs
|
||||||
|
// `@microsoft/rush@${RUSH_VERSION:-latest}`).
|
||||||
|
// * `pnpmVersion` ← `pnpm --version` (image installs
|
||||||
|
// `pnpm@${PNPM_VERSION:-latest}`). Rush downloads its own copy of this
|
||||||
|
// into `~/.rush/...`; using the same exact version as the system pnpm
|
||||||
|
// just keeps the fixture in lockstep with whatever the CI matrix picks.
|
||||||
|
|
||||||
|
/** Resolves the versions to put into `rush.json`. */
|
||||||
|
export async function resolveRushVersions(shell) {
|
||||||
|
// Sequential: the helper drives a single PTY shell.
|
||||||
|
const rushVersion = await getInstalledVersion(shell, "rush");
|
||||||
|
const pnpmVersion = await getInstalledVersion(shell, "pnpm");
|
||||||
|
return { rushVersion, pnpmVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the standard `rush.json` body for the e2e fixtures. */
|
||||||
|
export function buildRushConfig({ rushVersion, pnpmVersion, projects }) {
|
||||||
|
return {
|
||||||
|
$schema:
|
||||||
|
"https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json",
|
||||||
|
rushVersion,
|
||||||
|
pnpmVersion,
|
||||||
|
nodeSupportedVersionRange: ">=18.0.0",
|
||||||
|
projectFolderMinDepth: 1,
|
||||||
|
projectFolderMaxDepth: 2,
|
||||||
|
gitPolicy: {},
|
||||||
|
repository: {
|
||||||
|
url: "https://example.com/testapp.git",
|
||||||
|
defaultBranch: "main",
|
||||||
|
},
|
||||||
|
eventHooks: {
|
||||||
|
preRushInstall: [],
|
||||||
|
postRushInstall: [],
|
||||||
|
preRushBuild: [],
|
||||||
|
postRushBuild: [],
|
||||||
|
},
|
||||||
|
projects: projects ?? [
|
||||||
|
{ packageName: "test-app", projectFolder: "apps/test-app" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a UTF-8 text file inside the container, base64-encoding the payload
|
||||||
|
* to avoid shell escaping issues for arbitrary content.
|
||||||
|
*/
|
||||||
|
export async function writeTextFile(shell, filePath, content) {
|
||||||
|
const encoded = Buffer.from(content).toString("base64");
|
||||||
|
await shell.runCommand(`printf '%s' '${encoded}' | base64 -d > ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInstalledVersion(shell, command) {
|
||||||
|
const { output } = await shell.runCommand(`${command} --version`);
|
||||||
|
const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not determine installed ${command} version. Output was:\n${output}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
@ -126,15 +126,16 @@ describe("E2E: uv coverage", () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"uv pip install --system --break-system-packages safe-chain-pi-test"
|
"uv pip install --system --break-system-packages numpy==2.4.4"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads:"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads:/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("safe_chain_pi_test@0.0.1"),
|
result.output.includes("numpy@2.4.4"),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -144,7 +145,7 @@ describe("E2E: uv coverage", () => {
|
||||||
|
|
||||||
const listResult = await shell.runCommand("uv pip list --system");
|
const listResult = await shell.runCommand("uv pip list --system");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!listResult.output.includes("safe-chain-pi-test"),
|
!listResult.output.includes("numpy"),
|
||||||
`Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}`
|
`Malicious package was installed despite safe-chain protection. Output of 'uv pip list' was:\n${listResult.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -413,15 +414,16 @@ describe("E2E: uv coverage", () => {
|
||||||
await shell.runCommand("uv init test-project-malware");
|
await shell.runCommand("uv init test-project-malware");
|
||||||
|
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"cd test-project-malware && uv add safe-chain-pi-test"
|
"cd test-project-malware && uv add numpy==2.4.4"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads:"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads:/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("safe_chain_pi_test@0.0.1"),
|
result.output.includes("numpy@2.4.4"),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
@ -445,14 +447,15 @@ describe("E2E: uv coverage", () => {
|
||||||
|
|
||||||
it(`safe-chain blocks malicious packages via uv tool install`, async () => {
|
it(`safe-chain blocks malicious packages via uv tool install`, async () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
const result = await shell.runCommand("uv tool install safe-chain-pi-test");
|
const result = await shell.runCommand("uv tool install numpy==2.4.4");
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads:"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads:/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("safe_chain_pi_test@0.0.1"),
|
result.output.includes("numpy@2.4.4"),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -482,11 +485,12 @@ describe("E2E: uv coverage", () => {
|
||||||
await shell.runCommand("echo 'print(\"test\")' > test_script2.py");
|
await shell.runCommand("echo 'print(\"test\")' > test_script2.py");
|
||||||
|
|
||||||
const result = await shell.runCommand(
|
const result = await shell.runCommand(
|
||||||
"uv run --with safe-chain-pi-test test_script2.py"
|
"uv run --with numpy==2.4.4 test_script2.py"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads:"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads:/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
132
test/e2e/uvx.e2e.spec.js
Normal file
132
test/e2e/uvx.e2e.spec.js
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("E2E: uvx coverage", () => {
|
||||||
|
let container;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
DockerTestContainer.buildImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
container = new DockerTestContainer();
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
const installationShell = await container.openShell("zsh");
|
||||||
|
await installationShell.runCommand("safe-chain setup");
|
||||||
|
|
||||||
|
// Clear uv cache
|
||||||
|
await installationShell.runCommand("uv cache clean");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (container) {
|
||||||
|
await container.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`successfully runs a known safe tool with uvx`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"uvx ruff --version --safe-chain-logging=verbose"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || /ruff/i.test(result.output),
|
||||||
|
`Expected safe tool to run successfully. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks malicious packages via uvx`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"uvx numpy==2.4.4"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked by safe-chain"),
|
||||||
|
`Expected malicious package to be blocked. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Expected exit message. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`uvx with --from flag runs a safe tool`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"uvx --from ruff ruff --version --safe-chain-logging=verbose"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || /ruff/i.test(result.output),
|
||||||
|
`Expected safe tool to run successfully with --from. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`uvx with --from flag blocks malicious packages`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"uvx --from numpy==2.4.4 some-command"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked by safe-chain"),
|
||||||
|
`Expected malicious package to be blocked with --from. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Expected exit message. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`uvx with specific version runs successfully`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"uvx ruff@0.4.0 --version --safe-chain-logging=verbose"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || /ruff/i.test(result.output),
|
||||||
|
`Expected safe tool with version to run. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`uvx with --with flag for additional dependencies`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"uvx --with requests ruff --version --safe-chain-logging=verbose"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("no malware found.") || /ruff/i.test(result.output),
|
||||||
|
`Expected safe tool with --with dependency to run. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`uvx with --with flag blocks malicious additional dependencies`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
|
||||||
|
const result = await shell.runCommand(
|
||||||
|
"uvx --with numpy==2.4.4 ruff --version"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked by safe-chain"),
|
||||||
|
`Expected malicious --with dependency to be blocked. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Expected exit message. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -70,8 +70,9 @@ describe("E2E: yarn coverage", () => {
|
||||||
|
|
||||||
var result = await shell.runCommand("yarn");
|
var result = await shell.runCommand("yarn");
|
||||||
|
|
||||||
assert.ok(
|
assert.match(
|
||||||
result.output.includes("blocked 1 malicious package downloads"),
|
result.output,
|
||||||
|
/blocked [1-9]\d* malicious package downloads/,
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue