|
#!/usr/bin/env bash |
|
# check.sh - Supply Chain Exposure Checker |
|
# Checks for known-bad versions of litellm, axios, and plain-crypto-js |
|
# If no directories are given, scans $HOME. |
|
# |
|
# These are two separate incidents: |
|
# LiteLLM: March 24, 2026 — compromised PyPI releases 1.82.7 and 1.82.8 |
|
# Axios: March 31, 2026 — compromised npm releases 1.14.1 and 0.30.4 |
|
# with malicious dep plain-crypto-js@4.2.1 |
|
# |
|
# Usage: bash check.sh [directory ...] |
|
|
|
set -euo pipefail |
|
|
|
# --- incident metadata ------------------------------------------------------ |
|
LITELLM_BAD="1.82.7|1.82.8" |
|
AXIOS_BAD="1.14.1|0.30.4" |
|
CRYPTO_BAD="plain-crypto-js" |
|
|
|
LITELLM_WINDOW="March 24, 2026 10:39-16:00 UTC" |
|
AXIOS_WINDOW="March 31, 2026 00:21-03:20 UTC" |
|
|
|
RED='\033[0;31m' |
|
GREEN='\033[0;32m' |
|
YELLOW='\033[1;33m' |
|
BOLD='\033[1m' |
|
RESET='\033[0m' |
|
|
|
found_count=0 |
|
|
|
warn() { echo -e "${RED}[!]${RESET} $*"; found_count=$((found_count + 1)); } |
|
ok() { echo -e "${GREEN}[✓]${RESET} $*"; } |
|
info() { echo -e "${YELLOW}[·]${RESET} $*"; } |
|
|
|
echo "" |
|
echo -e "${BOLD}Supply Chain Exposure Checker${RESET}" |
|
echo "LiteLLM window: ${LITELLM_WINDOW}" |
|
echo "Axios window: ${AXIOS_WINDOW}" |
|
echo "Checks for:" |
|
echo " - litellm 1.82.7 / 1.82.8" |
|
echo " - axios 1.14.1 / 0.30.4" |
|
echo " - plain-crypto-js 4.2.1" |
|
echo "" |
|
|
|
SCAN_DIRS=("${@:-$HOME}") |
|
|
|
# --- helpers ---------------------------------------------------------------- |
|
|
|
find_multi() { |
|
local args=() |
|
local first=true |
|
for pattern in "$@"; do |
|
if [ "$first" = true ]; then |
|
first=false |
|
else |
|
args+=("-o") |
|
fi |
|
args+=("-name" "$pattern") |
|
done |
|
find "${SCAN_DIRS[@]}" -maxdepth 8 \ |
|
-not -path "*/.git/*" \ |
|
-not -path "*/.Trash/*" \ |
|
-not -path "*/node_modules/.cache/*" \ |
|
\( "${args[@]}" \) 2>/dev/null |
|
} |
|
|
|
# ============================================================================= |
|
# LITELLM (PyPI compromise — March 24, 2026) |
|
# ============================================================================= |
|
|
|
echo -e "${BOLD}--- LiteLLM (PyPI) ---${RESET}" |
|
|
|
litellm_found=false |
|
|
|
# --- 1a. requirements.txt / constraints.txt (plain line-based formats) ------ |
|
|
|
info "Scanning requirements/constraints files..." |
|
|
|
while IFS= read -r f; do |
|
[ -z "$f" ] && continue |
|
# Pinned to bad version |
|
if grep -qiE '^[[:space:]]*litellm([[:space:]]*\[[^]]+\])?[[:space:]]*(==|~=|>=|<=|>|<)?[[:space:]]*('"${LITELLM_BAD}"')\b' "$f" 2>/dev/null; then |
|
warn "Bad litellm version pinned in: $f" |
|
grep -niE 'litellm' "$f" 2>/dev/null | head -3 |
|
litellm_found=true |
|
# Unpinned (bare litellm with no version specifier) |
|
elif grep -qiE '^[[:space:]]*litellm[[:space:]]*$' "$f" 2>/dev/null; then |
|
info "litellm with NO version pin in: $f (could have resolved to bad version)" |
|
litellm_found=true |
|
fi |
|
done < <(find_multi "requirements*.txt" "constraints*.txt") |
|
|
|
# --- 1b. pyproject.toml ---------------------------------------------------- |
|
|
|
info "Scanning pyproject.toml files..." |
|
|
|
while IFS= read -r f; do |
|
[ -z "$f" ] && continue |
|
# Matches: litellm = "1.82.8", litellm = "^1.82.8", litellm = {version = "~1.82.7"}, "litellm>=1.82.7" |
|
if grep -qiE 'litellm[[:space:]]*=[[:space:]]*("([^"]*'"1\.82\.(7|8)"'[^"]*)"|\{[^}]*version[[:space:]]*=[[:space:]]*"([^"]*'"1\.82\.(7|8)"'[^"]*)")' "$f" 2>/dev/null; then |
|
warn "Bad litellm version in: $f" |
|
grep -niE 'litellm' "$f" 2>/dev/null | head -3 |
|
litellm_found=true |
|
elif grep -qiE '"litellm[><=~!]*1\.82\.(7|8)' "$f" 2>/dev/null; then |
|
warn "Bad litellm version in dependency string: $f" |
|
grep -niE 'litellm' "$f" 2>/dev/null | head -3 |
|
litellm_found=true |
|
fi |
|
done < <(find_multi "pyproject.toml") |
|
|
|
# --- 1c. poetry.lock / pdm.lock ------------------------------------------- |
|
|
|
info "Scanning poetry/pdm lockfiles..." |
|
|
|
while IFS= read -r f; do |
|
[ -z "$f" ] && continue |
|
awk ' |
|
/name = "litellm"/ { in_pkg=1; next } |
|
in_pkg && /version = "1\.82\.(7|8)"/ { found=1 } |
|
/^\[\[/ { in_pkg=0 } |
|
END { exit(found ? 0 : 1) } |
|
' "$f" 2>/dev/null && { |
|
warn "Bad litellm version locked in: $f" |
|
litellm_found=true |
|
} |
|
done < <(find_multi "poetry.lock" "pdm.lock") |
|
|
|
# --- 1d. uv.lock ---------------------------------------------------------- |
|
|
|
info "Scanning uv lockfiles..." |
|
|
|
while IFS= read -r f; do |
|
[ -z "$f" ] && continue |
|
awk ' |
|
/name = "litellm"/ { in_pkg=1; next } |
|
in_pkg && /version = "1\.82\.(7|8)"/ { found=1 } |
|
/^\[\[/ { in_pkg=0 } |
|
END { exit(found ? 0 : 1) } |
|
' "$f" 2>/dev/null && { |
|
warn "Bad litellm version locked in: $f" |
|
litellm_found=true |
|
} |
|
done < <(find_multi "uv.lock") |
|
|
|
# --- 1e. Pipfile.lock (JSON) ----------------------------------------------- |
|
|
|
info "Scanning Pipfile.lock files..." |
|
|
|
while IFS= read -r f; do |
|
[ -z "$f" ] && continue |
|
# JSON structure: "litellm": { "version": "==1.82.7" } |
|
if grep -qE '"litellm"' "$f" 2>/dev/null; then |
|
if grep -A5 '"litellm"' "$f" 2>/dev/null | grep -qE '"version"[[:space:]]*:[[:space:]]*"==('"${LITELLM_BAD}"')"'; then |
|
warn "Bad litellm version in: $f" |
|
litellm_found=true |
|
fi |
|
fi |
|
done < <(find_multi "Pipfile.lock") |
|
|
|
# --- 1f. Pipfile / setup.cfg / setup.py ------------------------------------ |
|
|
|
info "Scanning Pipfile/setup files..." |
|
|
|
while IFS= read -r f; do |
|
[ -z "$f" ] && continue |
|
if grep -qiE 'litellm.*('"${LITELLM_BAD}"')' "$f" 2>/dev/null; then |
|
warn "Bad litellm version in: $f" |
|
grep -niE 'litellm' "$f" 2>/dev/null | head -3 |
|
litellm_found=true |
|
fi |
|
done < <(find_multi "Pipfile" "setup.cfg" "setup.py") |
|
|
|
# --- 2. Installed litellm in venvs / site-packages -------------------------- |
|
|
|
info "Scanning Python environments for installed litellm..." |
|
|
|
while IFS= read -r meta; do |
|
[ -z "$meta" ] && continue |
|
version=$(grep -i "^Version:" "$meta" 2>/dev/null | head -1 | awk '{print $2}') |
|
if echo "$version" | grep -qE "^(${LITELLM_BAD})$"; then |
|
warn "BAD litellm ${version} installed at: $meta" |
|
litellm_found=true |
|
elif [ -n "$version" ]; then |
|
ok "litellm ${version} installed at: $(dirname "$meta") (safe version)" |
|
fi |
|
done < <(find "${SCAN_DIRS[@]}" -maxdepth 8 -path "*/site-packages/litellm-*.dist-info/METADATA" 2>/dev/null || true) |
|
|
|
# Active python environments |
|
for pip_cmd in pip pip3 "python3 -m pip" "python -m pip"; do |
|
version=$($pip_cmd show litellm 2>/dev/null | grep -i "^Version:" | awk '{print $2}' || true) |
|
if echo "$version" | grep -qE "^(${LITELLM_BAD})$"; then |
|
warn "BAD litellm ${version} installed in active env ($pip_cmd)" |
|
litellm_found=true |
|
elif [ -n "$version" ]; then |
|
ok "litellm ${version} in active env ($pip_cmd) -- safe" |
|
fi |
|
done |
|
|
|
# --- 3. Dockerfiles and shell scripts with unpinned litellm installs -------- |
|
|
|
info "Scanning Dockerfiles and scripts for unpinned litellm installs..." |
|
|
|
SELF_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" |
|
while IFS= read -r f; do |
|
[ -z "$f" ] && continue |
|
# Skip self |
|
[ "$(cd "$(dirname "$f")" && pwd)/$(basename "$f")" = "$SELF_PATH" ] 2>/dev/null && continue |
|
# pip install litellm without version pin |
|
if grep -nE '\bpip3?\s+install\b' "$f" 2>/dev/null | grep -iE '\blitellm\b' | grep -qvE '(==|~=|>=|<=|>|<)[[:space:]]*[0-9]'; then |
|
info "Unpinned 'pip install litellm' in: $f (check if built during ${LITELLM_WINDOW})" |
|
grep -nE '\bpip3?\s+install\b.*\blitellm\b' "$f" 2>/dev/null | head -3 |
|
litellm_found=true |
|
fi |
|
# uv pip install litellm without version pin |
|
if grep -nE '\buv\s+pip\s+install\b' "$f" 2>/dev/null | grep -iE '\blitellm\b' | grep -qvE '(==|~=|>=|<=|>|<)[[:space:]]*[0-9]'; then |
|
info "Unpinned 'uv pip install litellm' in: $f (check if built during ${LITELLM_WINDOW})" |
|
litellm_found=true |
|
fi |
|
done < <(find_multi "Dockerfile" "Dockerfile.*" "*.sh" "*.bash" ".gitlab-ci.yml" ".github" "Makefile") |
|
|
|
if [ "$litellm_found" = false ]; then |
|
ok "No litellm exposure found" |
|
fi |
|
|
|
# ============================================================================= |
|
# AXIOS / plain-crypto-js (npm compromise — March 31, 2026) |
|
# ============================================================================= |
|
|
|
echo "" |
|
echo -e "${BOLD}--- Axios / plain-crypto-js (npm) ---${RESET}" |
|
|
|
axios_found=false |
|
|
|
# --- 4a. JS lockfiles for bad axios versions -------------------------------- |
|
|
|
info "Scanning JS lockfiles for axios ${AXIOS_BAD//|/ / }..." |
|
|
|
while IFS= read -r f; do |
|
[ -z "$f" ] && continue |
|
if grep -qE "axios[@/\"': ]*(${AXIOS_BAD})" "$f" 2>/dev/null; then |
|
warn "Bad axios version in lockfile: $f" |
|
grep -nE "axios.*(${AXIOS_BAD})" "$f" 2>/dev/null | head -5 |
|
axios_found=true |
|
fi |
|
done < <(find_multi "package-lock.json" "yarn.lock" "pnpm-lock.yaml" "npm-shrinkwrap.json") |
|
|
|
# --- 4b. node_modules for bad axios ---------------------------------------- |
|
|
|
while IFS= read -r pkg; do |
|
[ -z "$pkg" ] && continue |
|
version=$(grep -o '"version": *"[^"]*"' "$pkg" 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') |
|
if echo "$version" | grep -qE "^(${AXIOS_BAD})$"; then |
|
warn "BAD axios ${version} installed at: $(dirname "$pkg")" |
|
axios_found=true |
|
fi |
|
done < <(find "${SCAN_DIRS[@]}" -maxdepth 8 -path "*/node_modules/axios/package.json" 2>/dev/null || true) |
|
|
|
if [ "$axios_found" = false ]; then |
|
ok "No bad axios versions found in lockfiles or node_modules" |
|
fi |
|
|
|
# --- 4c. plain-crypto-js (the malicious dependency) ------------------------ |
|
|
|
echo "" |
|
info "Scanning for plain-crypto-js (malicious axios dependency)..." |
|
|
|
crypto_found=false |
|
while IFS= read -r f; do |
|
[ -z "$f" ] && continue |
|
if grep -qlE "${CRYPTO_BAD}(@|[^0-9]*(4\.2\.1))?" "$f" 2>/dev/null; then |
|
warn "plain-crypto-js referenced in: $f" |
|
grep -nE "${CRYPTO_BAD}" "$f" 2>/dev/null | head -3 |
|
crypto_found=true |
|
fi |
|
done < <(find_multi \ |
|
"package-lock.json" "yarn.lock" "pnpm-lock.yaml" "npm-shrinkwrap.json" "package.json") |
|
|
|
# Check node_modules |
|
if find "${SCAN_DIRS[@]}" -maxdepth 8 -path "*/node_modules/plain-crypto-js" -print -quit 2>/dev/null | grep -q .; then |
|
warn "plain-crypto-js installed in node_modules!" |
|
crypto_found=true |
|
fi |
|
|
|
if [ "$crypto_found" = false ]; then |
|
ok "No plain-crypto-js found" |
|
fi |
|
|
|
# --- 4d. Filesystem IOCs from axios payload --------------------------------- |
|
|
|
echo "" |
|
info "Scanning for axios compromise filesystem artifacts (IOCs)..." |
|
|
|
ioc_found=false |
|
|
|
# macOS |
|
if [ -e "/Library/Caches/com.apple.act.mond" ]; then |
|
warn "Axios IOC found: /Library/Caches/com.apple.act.mond" |
|
ioc_found=true |
|
fi |
|
|
|
# Linux |
|
if [ -e "/tmp/ld.py" ]; then |
|
warn "Axios IOC found: /tmp/ld.py" |
|
ioc_found=true |
|
fi |
|
|
|
# WSL / Windows-mounted paths (best effort) |
|
for p in \ |
|
"/mnt/c/ProgramData/wt.exe" \ |
|
"/mnt/c/Windows/Temp/6202033.vbs" \ |
|
"/mnt/c/Windows/Temp/6202033.ps1"; do |
|
if [ -e "$p" ]; then |
|
warn "Axios IOC found: $p" |
|
ioc_found=true |
|
fi |
|
done |
|
|
|
if [ "$ioc_found" = false ]; then |
|
ok "No known axios IOC artifacts found" |
|
else |
|
warn "IOC artifacts detected -- this machine may have executed the malicious payload" |
|
echo " Treat this machine as compromised. Rotate all secrets and rebuild from clean image." |
|
fi |
|
|
|
# ============================================================================= |
|
# PACKAGE MANAGER CACHES |
|
# ============================================================================= |
|
|
|
echo "" |
|
echo -e "${BOLD}--- Package manager caches ---${RESET}" |
|
|
|
cache_hit=false |
|
|
|
# pip cache |
|
for cache_dir in \ |
|
"${HOME}/Library/Caches/pip" \ |
|
"${HOME}/.cache/pip" \ |
|
"${XDG_CACHE_HOME:-${HOME}/.cache}/pip"; do |
|
if [ -d "$cache_dir" ]; then |
|
if find "$cache_dir" \( -name "litellm-1.82.7*" -o -name "litellm-1.82.8*" \) -print -quit 2>/dev/null | grep -q .; then |
|
warn "Cached bad litellm wheel/sdist in: $cache_dir" |
|
cache_hit=true |
|
fi |
|
fi |
|
done |
|
|
|
# uv cache |
|
for cache_dir in \ |
|
"${HOME}/Library/Caches/uv" \ |
|
"${HOME}/.cache/uv" \ |
|
"${XDG_CACHE_HOME:-${HOME}/.cache}/uv"; do |
|
if [ -d "$cache_dir" ]; then |
|
if find "$cache_dir" \( -name "litellm-1.82.7*" -o -name "litellm-1.82.8*" \) -print -quit 2>/dev/null | grep -q .; then |
|
warn "Cached bad litellm in uv cache: $cache_dir" |
|
cache_hit=true |
|
fi |
|
fi |
|
done |
|
|
|
# npm cache |
|
for cache_dir in \ |
|
"${HOME}/.npm" \ |
|
"${HOME}/AppData/Local/npm-cache" \ |
|
"${XDG_CACHE_HOME:-${HOME}/.cache}/npm"; do |
|
if [ -d "$cache_dir" ]; then |
|
if find "$cache_dir" \( \ |
|
-iname '*axios*1.14.1*' -o \ |
|
-iname '*axios*0.30.4*' -o \ |
|
-iname '*plain-crypto-js*' \ |
|
\) -print -quit 2>/dev/null | grep -q .; then |
|
warn "Suspicious npm cache entry in: $cache_dir" |
|
cache_hit=true |
|
fi |
|
fi |
|
done |
|
|
|
if [ "$cache_hit" = false ]; then |
|
ok "Package manager caches clean (pip, uv, npm)" |
|
fi |
|
|
|
# ============================================================================= |
|
# DOCKER IMAGES |
|
# ============================================================================= |
|
|
|
echo "" |
|
echo -e "${BOLD}--- Docker images ---${RESET}" |
|
|
|
if command -v docker &>/dev/null && docker info &>/dev/null 2>&1; then |
|
info "Scanning Docker images for compromised packages..." |
|
docker_hit=false |
|
while IFS= read -r image; do |
|
[ -z "$image" ] && continue |
|
|
|
# Check litellm (Python) |
|
result=$(docker run --rm --entrypoint="" "$image" \ |
|
pip show litellm 2>/dev/null | grep -i "^Version:" | awk '{print $2}' || true) 2>/dev/null |
|
if echo "$result" | grep -qE "^(${LITELLM_BAD})$"; then |
|
warn "Docker image ${image} has BAD litellm ${result}" |
|
docker_hit=true |
|
fi |
|
|
|
# Check axios / plain-crypto-js (Node) |
|
node_result=$(docker run --rm --entrypoint="" "$image" sh -c ' |
|
for f in $(find / -path "*/node_modules/axios/package.json" -o -path "*/node_modules/plain-crypto-js/package.json" 2>/dev/null | head -10); do |
|
echo "$f: $(grep -o "\"version\": *\"[^\"]*\"" "$f" 2>/dev/null | head -1)" |
|
done |
|
' 2>/dev/null || true) |
|
if echo "$node_result" | grep -qE "axios.*\"(${AXIOS_BAD})\""; then |
|
warn "Docker image ${image} has BAD axios: $node_result" |
|
docker_hit=true |
|
fi |
|
if echo "$node_result" | grep -q "plain-crypto-js"; then |
|
warn "Docker image ${image} has plain-crypto-js: $node_result" |
|
docker_hit=true |
|
fi |
|
|
|
done < <(docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -v '<none>' | head -20) |
|
if [ "$docker_hit" = false ]; then |
|
ok "No compromised packages in local Docker images (checked up to 20)" |
|
fi |
|
else |
|
info "Docker not available or not running -- skipping image scan" |
|
fi |
|
|
|
# ============================================================================= |
|
# SUMMARY |
|
# ============================================================================= |
|
|
|
echo "" |
|
echo "=============================================" |
|
if [ "$found_count" -gt 0 ]; then |
|
echo -e "${RED}${BOLD}FOUND ${found_count} ISSUE(S)${RESET}" |
|
echo "" |
|
echo "Recommended actions:" |
|
echo " 1. Rotate ALL secrets accessible to affected machines/envs" |
|
echo " (cloud creds, API keys, SSH keys, GitHub tokens, DB passwords)" |
|
echo " 2. Rebuild affected containers/envs from clean base images" |
|
echo " 3. Update to safe versions:" |
|
echo " - litellm >= 1.83.0" |
|
echo " - axios: pin to 1.14.0 (1.x) or 0.30.3 (0.x), then upgrade after verifying upstream" |
|
echo " 4. Clear package caches: pip cache purge / npm cache clean --force" |
|
echo " 5. Check CI/CD build logs for:" |
|
echo " - LiteLLM: pip installs on March 24, 2026" |
|
echo " - Axios: npm/yarn/pnpm installs on March 31, 2026" |
|
echo " - Look for: axios@1.14.1, axios@0.30.4, plain-crypto-js@4.2.1, postinstall execution" |
|
echo "" |
|
echo "References:" |
|
echo " https://docs.litellm.ai/blog/security-update-march-2026" |
|
echo " https://socket.dev/blog/axios-npm-compromise" |
|
exit 1 |
|
else |
|
echo -e "${GREEN}${BOLD}NO KNOWN MATCHES FOUND IN SCANNED PATHS${RESET}" |
|
echo "" |
|
echo -e "${BOLD}This is a local best-effort scan, not a forensic guarantee.${RESET}" |
|
echo "The axios payload may self-delete after execution -- a clean" |
|
echo "filesystem does not prove the system was never exposed." |
|
echo "" |
|
echo "This scan checked:" |
|
echo " * Python deps (requirements, pyproject.toml, lockfiles, venvs, active env)" |
|
echo " * Dockerfiles and scripts for unpinned litellm installs" |
|
echo " * JS deps (lockfiles, node_modules, npm-shrinkwrap)" |
|
echo " * Axios filesystem IOCs (macOS, Linux, Windows/WSL)" |
|
echo " * Package manager caches (pip, uv, npm)" |
|
echo " * Docker images (Python and Node)" |
|
echo "" |
|
echo "This does NOT check:" |
|
echo " * Remote CI/CD (GitHub Actions, GitLab CI, etc.)" |
|
echo " * Cloud deploy hosts (Render, Vercel, AWS, etc.)" |
|
echo " * bun.lockb (binary format, not grep-able)" |
|
echo "" |
|
echo "Review build logs for:" |
|
echo " LiteLLM: pip install runs on March 24, 2026" |
|
echo " Axios: npm/yarn/pnpm installs on March 31, 2026" |
|
echo " look for axios@1.14.1, axios@0.30.4," |
|
echo " plain-crypto-js@4.2.1, postinstall execution" |
|
echo "" |
|
echo "Run this script on each machine and check build logs separately." |
|
exit 0 |
|
fi |