Created
March 25, 2026 22:51
-
-
Save gabrielstellini/eb8cfe10f1dc7871d238d6ec56fe70b3 to your computer and use it in GitHub Desktop.
Scans your local system for the litellm vulnerability
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| # LiteLLM Supply Chain Attack Scanner (PYSEC-2026-2) — v3 (merged) | |
| # | |
| # Comprehensive scanner combining thorough package discovery (all Python | |
| # environments, dependency/lock files, caches) with runtime IOC detection | |
| # (persistence artifacts, network indicators, processes, Kubernetes). | |
| # | |
| # Malicious versions: 1.82.7 and 1.82.8 only (published 2026-03-24 by TeamPCP). | |
| # | |
| # Environment variables: | |
| # LITELLM_SCAN_ROOT — override root directory to scan (default: $HOME) | |
| # | |
| # Exit codes: | |
| # 0 — clean (no confirmed issues) | |
| # 1 — compromised (confirmed malicious artifacts found) | |
| # | |
| # References: | |
| # - https://github.com/BerriAI/litellm/issues/24518 | |
| # - PYSEC-2026-2 | |
| # - https://futuresearch.ai/blog/litellm-pypi-supply-chain-attack/ | |
| # ────────────────────────────────────────────────────────────────────────────── | |
| set -uo pipefail | |
| # ── Colors ─────────────────────────────────────────────────────────────────── | |
| RED='\033[0;31m' | |
| YELLOW='\033[1;33m' | |
| GREEN='\033[0;32m' | |
| CYAN='\033[0;36m' | |
| BOLD='\033[1m' | |
| NC='\033[0m' | |
| # ── IOC Data ───────────────────────────────────────────────────────────────── | |
| MALICIOUS_VERSIONS=("1.82.7" "1.82.8") | |
| PTH_HASH="71e35aef03099cd1f2d6446734273025a163597de93912df321ef118bf135238" | |
| MALICIOUS_DOMAINS=("models.litellm.cloud" "checkmarx.zone") | |
| MALICIOUS_IPS=("46.151.182.203" "83.142.209.11") | |
| KNOWN_PTH_REGEX="^(_virtualenv|_uv_ephemeral_overlay|easy-install|distutils|distutils-precedence|setuptools|wheel|pip|pkg_resources|aeosa|__editable__\..+|coloredlogs|a1_coverage|coverage|debugpy|inject|wrapt)$" | |
| # ── Configuration ──────────────────────────────────────────────────────────── | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| SCAN_ROOT="${LITELLM_SCAN_ROOT:-$HOME}" | |
| FIXTURE_EXCLUDE="" | |
| if [[ -z "${LITELLM_SCAN_ROOT:-}" ]] && [[ -d "$SCRIPT_DIR/tests/fixtures" ]]; then | |
| FIXTURE_EXCLUDE="$SCRIPT_DIR/tests/fixtures" | |
| fi | |
| if [[ -n "${LITELLM_SCAN_ROOT:-}" ]]; then | |
| BROAD_SCAN_ROOTS=("$LITELLM_SCAN_ROOT") | |
| else | |
| BROAD_SCAN_ROOTS=("$HOME" "/usr/local/lib" "/opt/homebrew/lib" "/Library/Python") | |
| fi | |
| # ── Counters ───────────────────────────────────────────────────────────────── | |
| found_count=0 | |
| warn_count=0 | |
| # ── Output helpers ─────────────────────────────────────────────────────────── | |
| banner() { | |
| echo "" | |
| echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════════════${NC}" | |
| echo -e "${CYAN}${BOLD} LiteLLM Supply Chain Attack Scanner (PYSEC-2026-2) v3${NC}" | |
| echo -e "${CYAN}${BOLD} Malicious versions: 1.82.7, 1.82.8 (published 2026-03-24)${NC}" | |
| echo -e "${CYAN}${BOLD}════════════════════════════════════════════════════════════════${NC}" | |
| echo "" | |
| } | |
| section() { | |
| echo "" | |
| echo -e "${BOLD}── $1 ──${NC}" | |
| } | |
| found() { | |
| echo -e " ${RED}[FOUND]${NC} $1" | |
| found_count=$((found_count + 1)) | |
| } | |
| warn() { | |
| echo -e " ${YELLOW}[WARN]${NC} $1" | |
| warn_count=$((warn_count + 1)) | |
| } | |
| clean() { | |
| echo -e " ${GREEN}[CLEAN]${NC} $1" | |
| } | |
| info() { | |
| echo -e " ${CYAN}[INFO]${NC} $1" | |
| } | |
| # ── Helpers ────────────────────────────────────────────────────────────────── | |
| is_malicious() { | |
| local v="$1" | |
| for m in "${MALICIOUS_VERSIONS[@]}"; do | |
| [[ "$v" == "$m" ]] && return 0 | |
| done | |
| return 1 | |
| } | |
| sha256_of() { | |
| if command -v sha256sum &>/dev/null; then | |
| sha256sum "$1" | awk '{print $1}' | |
| elif command -v shasum &>/dev/null; then | |
| shasum -a 256 "$1" | awk '{print $1}' | |
| else | |
| openssl dgst -sha256 "$1" | awk '{print $NF}' | |
| fi | |
| } | |
| flag_pip_result() { | |
| local result="$1" source="$2" | |
| local ver loc | |
| ver=$(printf '%s' "$result" | grep "^Version:" | awk '{print $2}') || true | |
| loc=$(printf '%s' "$result" | grep "^Location:" | cut -d' ' -f2-) || true | |
| if [[ -z "$ver" ]]; then | |
| warn "Could not parse version from $source output" | |
| return | |
| fi | |
| if is_malicious "$ver"; then | |
| found "INFECTED — litellm==$ver [$source] at $loc" | |
| else | |
| clean "litellm==$ver [$source] at $loc" | |
| fi | |
| } | |
| # ── Find wrappers ──────────────────────────────────────────────────────────── | |
| # Build prune args once; reused by all collect_* functions. | |
| PRUNE_PATHS=(-path "*/.git" -o -path "*/node_modules" -o -path "*/__pycache__" -o -path "*/.cache" -o -path "*/Library/Caches") | |
| if [[ -n "$FIXTURE_EXCLUDE" ]]; then | |
| PRUNE_PATHS+=(-o -path "$FIXTURE_EXCLUDE") | |
| fi | |
| collect_dependency_files() { | |
| find "$SCAN_ROOT" -maxdepth 8 \ | |
| \( "${PRUNE_PATHS[@]}" \) -prune -o \ | |
| \( -name "requirements*.txt" -o -name "pyproject.toml" -o -name "setup.py" \ | |
| -o -name "setup.cfg" -o -name "Pipfile" -o -name "poetry.lock" \ | |
| -o -name "uv.lock" -o -name "pdm.lock" \) -print 2>/dev/null | |
| return 0 | |
| } | |
| collect_venv_dirs() { | |
| find "$SCAN_ROOT" -maxdepth 6 \ | |
| \( "${PRUNE_PATHS[@]}" \) -prune -o \ | |
| \( -type d \( -name ".venv" -o -name "venv" -o -name "env" \) -print \) 2>/dev/null | |
| return 0 | |
| } | |
| collect_broad_hits() { | |
| find "${BROAD_SCAN_ROOTS[@]}" -maxdepth 10 \ | |
| \( "${PRUNE_PATHS[@]}" \) -prune -o \ | |
| \( -name "litellm_init.pth" -o -name "litellm*.dist-info" -o -name "litellm*.egg-info" \) -print 2>/dev/null | |
| return 0 | |
| } | |
| get_site_packages() { | |
| { | |
| for py in python3 python python3.9 python3.10 python3.11 python3.12 python3.13; do | |
| command -v "$py" &>/dev/null || continue | |
| "$py" -c " | |
| import site | |
| pkgs = getattr(site, 'getsitepackages', lambda: [])() | |
| user = getattr(site, 'getusersitepackages', lambda: '')() | |
| for p in pkgs + ([user] if user else []): | |
| print(p) | |
| " 2>/dev/null || true | |
| done | |
| for prefix in /usr/local /opt/homebrew; do | |
| [[ -d "$prefix/lib" ]] && find "$prefix/lib" -maxdepth 4 -name "site-packages" -type d 2>/dev/null | |
| done | |
| local _pyenv_root="${PYENV_ROOT:-$HOME/.pyenv}" | |
| [[ -d "$_pyenv_root/versions" ]] && \ | |
| find "$_pyenv_root/versions" -maxdepth 6 -name "site-packages" -type d 2>/dev/null | |
| find /usr/lib /usr/local/lib /Library/Python \ | |
| -maxdepth 7 -name "site-packages" -type d 2>/dev/null || true | |
| find /System/Library/Frameworks/Python.framework \ | |
| -maxdepth 8 -name "site-packages" -type d 2>/dev/null || true | |
| } | sort -u | grep -v '^$' || true | |
| } | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # PART A: PACKAGE DISCOVERY | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # ── 1. Enumerate site-packages ─────────────────────────────────────────────── | |
| SITE_PACKAGES=() | |
| enumerate_site_packages() { | |
| section "1/13 Enumerating Python site-packages" | |
| while IFS= read -r p; do | |
| [[ -d "$p" ]] && SITE_PACKAGES+=("$p") | |
| done < <(get_site_packages) | |
| info "Found ${#SITE_PACKAGES[@]} site-packages root(s)" | |
| for sp in "${SITE_PACKAGES[@]+"${SITE_PACKAGES[@]}"}"; do | |
| info " $sp" | |
| done | |
| } | |
| # ── 2. pip global installs ─────────────────────────────────────────────────── | |
| check_pip_global() { | |
| section "2/13 pip global installs" | |
| local step_found=false | |
| local seen_pip_paths=() | |
| for cmd in pip pip3 pip3.9 pip3.10 pip3.11 pip3.12 pip3.13; do | |
| command -v "$cmd" &>/dev/null || continue | |
| # Dedup: skip if this resolves to an already-checked binary | |
| local resolved | |
| resolved=$(command -v "$cmd") || true | |
| local already=false | |
| local seen | |
| for seen in "${seen_pip_paths[@]+"${seen_pip_paths[@]}"}"; do | |
| [[ "$resolved" == "$seen" ]] && already=true && break | |
| done | |
| [[ "$already" == true ]] && continue | |
| seen_pip_paths+=("$resolved") | |
| local result | |
| result=$("$cmd" show litellm 2>/dev/null) || true | |
| if [[ -n "$result" ]]; then | |
| flag_pip_result "$result" "$cmd" | |
| step_found=true | |
| fi | |
| done | |
| [[ "$step_found" == false ]] && clean "Not found via any pip" | |
| } | |
| # ── 3. Known site-packages ────────────────────────────────────────────────── | |
| check_site_packages() { | |
| section "3/13 Installed litellm in site-packages" | |
| local step_found=false | |
| for sp in "${SITE_PACKAGES[@]+"${SITE_PACKAGES[@]}"}"; do | |
| [[ -d "$sp/litellm" ]] || continue | |
| local ver="" | |
| local dist_dir | |
| for dist_dir in "$sp"/litellm-*.dist-info; do | |
| [[ -d "$dist_dir" ]] || continue | |
| ver=$(basename "$dist_dir" | sed 's/litellm-\(.*\)\.dist-info/\1/') | |
| break | |
| done | |
| if [[ -n "$ver" ]]; then | |
| if is_malicious "$ver"; then | |
| found "INFECTED — litellm==$ver at $sp" | |
| else | |
| clean "litellm==$ver at $sp" | |
| fi | |
| else | |
| warn "litellm directory at $sp/litellm — no dist-info (source checkout? verify manually)" | |
| fi | |
| step_found=true | |
| done | |
| [[ "$step_found" == false ]] && clean "Not found in known site-packages" | |
| } | |
| # ── 4. conda environments ─────────────────────────────────────────────────── | |
| check_conda_envs() { | |
| section "4/13 conda environments" | |
| if ! command -v conda &>/dev/null; then | |
| info "conda not installed, skipping" | |
| return | |
| fi | |
| local step_found=false | |
| local env_name | |
| while IFS= read -r env_name; do | |
| [[ -z "$env_name" ]] && continue | |
| local result | |
| result=$(conda run -n "$env_name" pip show litellm 2>/dev/null) || true | |
| if [[ -n "$result" ]]; then | |
| flag_pip_result "$result" "conda:$env_name" | |
| step_found=true | |
| fi | |
| done < <(conda env list 2>/dev/null | awk '{print $1}' | grep -v "^#" | grep -v "^$" || true) | |
| [[ "$step_found" == false ]] && clean "Not found in any conda env" | |
| } | |
| # ── 5. pyenv versions ─────────────────────────────────────────────────────── | |
| check_pyenv() { | |
| section "5/13 pyenv versions" | |
| if ! command -v pyenv &>/dev/null; then | |
| info "pyenv not installed, skipping" | |
| return | |
| fi | |
| local step_found=false | |
| local pyenv_root="${PYENV_ROOT:-$HOME/.pyenv}" | |
| local ver | |
| while IFS= read -r ver; do | |
| [[ -z "$ver" ]] && continue | |
| local pip_path="$pyenv_root/versions/$ver/bin/pip" | |
| [[ -x "$pip_path" ]] || continue | |
| local result | |
| result=$("$pip_path" show litellm 2>/dev/null) || true | |
| if [[ -n "$result" ]]; then | |
| flag_pip_result "$result" "pyenv:$ver" | |
| step_found=true | |
| fi | |
| done < <(pyenv versions --bare 2>/dev/null || true) | |
| [[ "$step_found" == false ]] && clean "Not found in any pyenv version" | |
| } | |
| # ── 6. Virtual environments (.venv / venv / env) ──────────────────────────── | |
| check_venvs() { | |
| section "6/13 Virtual environments" | |
| info "Scanning for virtualenvs under $SCAN_ROOT..." | |
| local step_found=false | |
| local venv_path | |
| while IFS= read -r venv_path; do | |
| [[ -z "$venv_path" ]] && continue | |
| # Skip venvs whose site-packages were already covered by step 3 | |
| local sp_dir | |
| sp_dir=$(find "$venv_path/lib" -maxdepth 2 -type d -name "site-packages" 2>/dev/null | head -1) || true | |
| if [[ -n "$sp_dir" ]]; then | |
| # Already reported by check_site_packages — skip | |
| local already_seen=false | |
| for seen_sp in "${SITE_PACKAGES[@]+"${SITE_PACKAGES[@]}"}"; do | |
| [[ "$sp_dir" == "$seen_sp" ]] && already_seen=true && break | |
| done | |
| [[ "$already_seen" == true ]] && continue | |
| # Fast pre-filter: skip venvs that don't have litellm installed | |
| [[ ! -d "$sp_dir/litellm" ]] && continue | |
| fi | |
| local pip_bin="$venv_path/bin/pip" | |
| [[ -x "$pip_bin" ]] || continue | |
| local result | |
| result=$("$pip_bin" show litellm 2>/dev/null) || true | |
| if [[ -n "$result" ]]; then | |
| flag_pip_result "$result" "venv:$venv_path" | |
| step_found=true | |
| fi | |
| done < <(collect_venv_dirs) | |
| [[ "$step_found" == false ]] && clean "Not found in any virtual env" | |
| } | |
| # ── 7. uv active environment ──────────────────────────────────────────────── | |
| check_uv() { | |
| section "7/13 uv active environment" | |
| if ! command -v uv &>/dev/null; then | |
| info "uv not installed, skipping" | |
| return | |
| fi | |
| local result | |
| result=$(uv pip show litellm 2>/dev/null) || true | |
| if [[ -z "$result" ]]; then | |
| clean "Not found in uv default environment" | |
| return | |
| fi | |
| # Skip if this location was already checked by step 2/3 | |
| local uv_loc | |
| uv_loc=$(printf '%s' "$result" | grep "^Location:" | cut -d' ' -f2-) || true | |
| if [[ -n "$uv_loc" ]]; then | |
| local already_seen=false | |
| for seen_sp in "${SITE_PACKAGES[@]+"${SITE_PACKAGES[@]}"}"; do | |
| [[ "$uv_loc" == "$seen_sp" ]] && already_seen=true && break | |
| done | |
| if [[ "$already_seen" == true ]]; then | |
| info "uv environment already covered by earlier checks" | |
| return | |
| fi | |
| fi | |
| flag_pip_result "$result" "uv" | |
| } | |
| # ── 8. Dependency files and lock files ─────────────────────────────────────── | |
| check_dependency_files() { | |
| section "8/13 Dependency files and lock files" | |
| local step_found=false | |
| local pat_exact='litellm([^=[:space:]]*)?\s*==\s*["'"'"']?(1\.82\.7|1\.82\.8)' | |
| local pat_range='litellm([^=[:space:]]*)?\s*(>=|~=)\s*["'"'"']?1\.82\.[0-8]' | |
| local f | |
| while IFS= read -r f; do | |
| [[ -z "$f" ]] && continue | |
| # Lock files: look for malicious version string near "litellm" | |
| if [[ "$f" == *.lock ]]; then | |
| if grep -qi "litellm" "$f" 2>/dev/null; then | |
| local lock_ver | |
| lock_ver=$(grep -A5 -i "litellm" "$f" 2>/dev/null \ | |
| | grep -oE '"(1\.82\.7|1\.82\.8)"' | head -1 | tr -d '"') || true | |
| if [[ -n "$lock_ver" ]]; then | |
| found "Lock file pins malicious litellm==$lock_ver: $f" | |
| step_found=true | |
| fi | |
| fi | |
| continue | |
| fi | |
| local matches | |
| matches=$(grep -i "litellm" "$f" 2>/dev/null | grep -ivE '^[[:space:]]*#') || true | |
| [[ -z "$matches" ]] && continue | |
| local line | |
| while IFS= read -r line; do | |
| [[ -z "$line" ]] && continue | |
| if echo "$line" | grep -qiE "$pat_exact"; then | |
| local pin_ver | |
| pin_ver=$(echo "$line" | grep -oE "1\.82\.[78]" | head -1) || true | |
| found "Pins malicious litellm==$pin_ver: $f" | |
| info " $line" | |
| elif echo "$line" | grep -qiE "$pat_range"; then | |
| warn "Version range may include malicious versions: $f" | |
| info " $line" | |
| else | |
| info "litellm reference (verify version): $f" | |
| info " $line" | |
| fi | |
| step_found=true | |
| done <<< "$matches" | |
| done < <(collect_dependency_files) | |
| [[ "$step_found" == false ]] && clean "No litellm references in dependency files" | |
| } | |
| # ── 9. Package manager caches (pip, uv, conda) ────────────────────────────── | |
| check_caches() { | |
| section "9/13 Package manager caches" | |
| local step_found=false | |
| # pip cache | |
| local pip_cache_dirs=() | |
| if command -v pip &>/dev/null; then | |
| local pip_cache | |
| pip_cache=$(pip cache dir 2>/dev/null) || true | |
| [[ -n "$pip_cache" && -d "$pip_cache" ]] && pip_cache_dirs+=("$pip_cache") | |
| fi | |
| [[ -d "$HOME/.cache/pip" ]] && pip_cache_dirs+=("$HOME/.cache/pip") | |
| [[ -d "$HOME/Library/Caches/pip" ]] && pip_cache_dirs+=("$HOME/Library/Caches/pip") | |
| local cache_dir | |
| for cache_dir in "${pip_cache_dirs[@]+"${pip_cache_dirs[@]}"}"; do | |
| while IFS= read -r -d '' f; do | |
| found "pip cache: $f" | |
| step_found=true | |
| done < <(find "$cache_dir" -type f \( -name "*litellm*1.82.7*" -o -name "*litellm*1.82.8*" \) -print0 2>/dev/null || true) | |
| done | |
| # uv cache | |
| local uv_cache_dirs=("$HOME/.cache/uv" "$HOME/Library/Caches/uv") | |
| for cache_dir in "${uv_cache_dirs[@]}"; do | |
| [[ -d "$cache_dir" ]] || continue | |
| while IFS= read -r -d '' dist_info; do | |
| local metadata_file="$dist_info/METADATA" | |
| if [[ -f "$metadata_file" ]]; then | |
| local ver | |
| ver=$(grep "^Version:" "$metadata_file" | awk '{print $2}') || true | |
| if is_malicious "${ver:-}"; then | |
| found "uv cache: litellm==$ver at $dist_info" | |
| step_found=true | |
| fi | |
| fi | |
| done < <(find "$cache_dir" -type d -name "litellm-*.dist-info" -print0 2>/dev/null || true) | |
| while IFS= read -r -d '' f; do | |
| found "uv cache: $f" | |
| step_found=true | |
| done < <(find "$cache_dir" -type f \( -name "*litellm*1.82.7*" -o -name "*litellm*1.82.8*" \) -print0 2>/dev/null || true) | |
| done | |
| # conda cache | |
| if [[ -d "$HOME/.conda/pkgs" ]]; then | |
| while IFS= read -r -d '' f; do | |
| found "conda cache: $f" | |
| step_found=true | |
| done < <(find "$HOME/.conda/pkgs" -maxdepth 1 -type d -name "litellm-1.82.[78]*" -print0 2>/dev/null || true) | |
| fi | |
| [[ "$step_found" == false ]] && clean "No compromised packages in caches" | |
| } | |
| # ── 10. dist-info / egg-info broad scan ────────────────────────────────────── | |
| check_dist_info() { | |
| section "10/13 dist-info / egg-info (broad scan)" | |
| local step_found=false | |
| local seen_paths=() | |
| # site-packages first | |
| local sp | |
| for sp in "${SITE_PACKAGES[@]+"${SITE_PACKAGES[@]}"}"; do | |
| local d | |
| for d in "$sp"/litellm-*.dist-info "$sp"/litellm-*.egg-info; do | |
| [[ -e "$d" ]] || continue | |
| seen_paths+=("$d") | |
| local ver | |
| ver=$(basename "$d" | sed -E 's/litellm-(.+)\.(dist-info|egg-info)$/\1/') | |
| if is_malicious "$ver"; then | |
| found "INFECTED dist-info litellm==$ver: $d" | |
| else | |
| clean "dist-info litellm==$ver: $d" | |
| fi | |
| step_found=true | |
| done | |
| done | |
| # Broad scan for anything not already caught | |
| local hit | |
| while IFS= read -r hit; do | |
| [[ -z "$hit" ]] && continue | |
| # Skip already-seen | |
| local already=false | |
| local s | |
| for s in "${seen_paths[@]+"${seen_paths[@]}"}"; do | |
| [[ "$hit" == "$s" ]] && already=true && break | |
| done | |
| [[ "$already" == true ]] && continue | |
| # Only process dist-info/egg-info, not .pth | |
| case "$hit" in | |
| *.dist-info|*.egg-info) | |
| local ver | |
| ver=$(basename "$hit" | sed -E 's/litellm-(.+)\.(dist-info|egg-info)$/\1/') | |
| if is_malicious "$ver"; then | |
| found "INFECTED dist-info litellm==$ver: $hit" | |
| else | |
| clean "dist-info litellm==$ver: $hit" | |
| fi | |
| step_found=true | |
| ;; | |
| esac | |
| done < <(collect_broad_hits) | |
| [[ "$step_found" == false ]] && clean "No litellm dist-info/egg-info found" | |
| } | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # PART B: MALWARE ARTIFACT DETECTION | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # ── 11. litellm_init.pth + suspicious .pth files ──────────────────────────── | |
| check_pth_files() { | |
| section "11/13 Malicious .pth files" | |
| local step_found=false | |
| # --- 11a. litellm_init.pth in known site-packages (fast) --- | |
| local sp | |
| for sp in "${SITE_PACKAGES[@]+"${SITE_PACKAGES[@]}"}"; do | |
| if [[ -f "$sp/litellm_init.pth" ]]; then | |
| local hash | |
| hash=$(sha256_of "$sp/litellm_init.pth") | |
| if [[ "$hash" == "$PTH_HASH" ]]; then | |
| found "EXACT HASH MATCH — litellm_init.pth at $sp (sha256: $hash)" | |
| else | |
| found "litellm_init.pth present (different hash): $sp" | |
| fi | |
| step_found=true | |
| fi | |
| done | |
| # --- 11b. litellm_init.pth broad scan --- | |
| local search_roots=("${BROAD_SCAN_ROOTS[@]}") | |
| [[ -n "${VIRTUAL_ENV:-}" ]] && search_roots+=("$VIRTUAL_ENV") | |
| while IFS= read -r -d '' pth_file; do | |
| # Skip if already reported from site-packages | |
| local already=false | |
| for sp in "${SITE_PACKAGES[@]+"${SITE_PACKAGES[@]}"}"; do | |
| [[ "$pth_file" == "$sp/litellm_init.pth" ]] && already=true && break | |
| done | |
| [[ "$already" == true ]] && continue | |
| local hash | |
| hash=$(sha256_of "$pth_file") | |
| if [[ "$hash" == "$PTH_HASH" ]]; then | |
| found "EXACT HASH MATCH — litellm_init.pth: $pth_file" | |
| else | |
| found "litellm_init.pth present (different hash): $pth_file" | |
| fi | |
| step_found=true | |
| done < <(find "${search_roots[@]}" -name "litellm_init.pth" -type f -print0 2>/dev/null || true) | |
| # --- 11c. Non-standard .pth files in site-packages (allowlist) --- | |
| for sp in "${SITE_PACKAGES[@]+"${SITE_PACKAGES[@]}"}"; do | |
| while IFS= read -r -d '' f; do | |
| local fname | |
| fname=$(basename "$f" .pth) | |
| # Use bash regex instead of forking grep per file | |
| if [[ "$fname" =~ $KNOWN_PTH_REGEX ]]; then | |
| continue | |
| fi | |
| if grep -q "base64" "$f" 2>/dev/null && grep -q "exec" "$f" 2>/dev/null; then | |
| found "Malicious .pth with base64+exec: $f" | |
| else | |
| warn "Unexpected .pth file (review manually): $f" | |
| fi | |
| step_found=true | |
| done < <(find "$sp" -maxdepth 1 -name "*.pth" -type f -print0 2>/dev/null || true) | |
| done | |
| # --- 11d. Broader base64+exec .pth scan --- | |
| while IFS= read -r -d '' pth_file; do | |
| # Skip site-packages (already covered) | |
| local in_sp=false | |
| for sp in "${SITE_PACKAGES[@]+"${SITE_PACKAGES[@]}"}"; do | |
| [[ "$pth_file" == "$sp/"* ]] && in_sp=true && break | |
| done | |
| [[ "$in_sp" == true ]] && continue | |
| if grep -q "base64" "$pth_file" 2>/dev/null && grep -q "exec" "$pth_file" 2>/dev/null; then | |
| found "Suspicious .pth with base64+exec: $pth_file" | |
| step_found=true | |
| fi | |
| done < <(find "${search_roots[@]}" -name "*.pth" -type f -size +1k -print0 2>/dev/null || true) | |
| [[ "$step_found" == false ]] && clean "No malicious .pth files found" | |
| } | |
| # ── 12. Persistence artifacts ──────────────────────────────────────────────── | |
| check_persistence() { | |
| section "12/13 Persistence artifacts (backdoor, systemd, LaunchAgents, temp files)" | |
| local step_found=false | |
| # Backdoor script — sysmon.py is the specific IOC, the directory alone is weaker signal | |
| local backdoor_files=( | |
| "$HOME/.config/sysmon/sysmon.py" | |
| ) | |
| if [[ "$(uname)" == "Darwin" ]]; then | |
| backdoor_files+=("$HOME/Library/Application Support/sysmon/sysmon.py") | |
| fi | |
| local p | |
| for p in "${backdoor_files[@]}"; do | |
| if [[ -f "$p" ]]; then | |
| found "Backdoor artifact: $p" | |
| if grep -qE "checkmarx\.zone|litellm\.cloud" "$p" 2>/dev/null; then | |
| found " Contains known C2 domain references" | |
| fi | |
| step_found=true | |
| fi | |
| done | |
| # Directory without sysmon.py is a weaker signal (could be legitimate monitoring) | |
| if [[ -d "$HOME/.config/sysmon" ]] && [[ ! -f "$HOME/.config/sysmon/sysmon.py" ]]; then | |
| warn "Directory $HOME/.config/sysmon/ exists without sysmon.py (may be remnant or legitimate)" | |
| fi | |
| # Systemd persistence (Linux) | |
| if [[ -f "$HOME/.config/systemd/user/sysmon.service" ]]; then | |
| found "Systemd persistence: $HOME/.config/systemd/user/sysmon.service" | |
| step_found=true | |
| fi | |
| if command -v systemctl &>/dev/null; then | |
| if systemctl --user is-active sysmon.service &>/dev/null; then | |
| found "Systemd service 'sysmon' is ACTIVE" | |
| step_found=true | |
| fi | |
| fi | |
| # LaunchAgent persistence (macOS) | |
| local launchagent_dir="$HOME/Library/LaunchAgents" | |
| if [[ -d "$launchagent_dir" ]]; then | |
| while IFS= read -r -d '' f; do | |
| # Strong signal: C2 domains unique to this attack | |
| if grep -qE "checkmarx\.zone|litellm\.cloud" "$f" 2>/dev/null; then | |
| found "LaunchAgent references C2 domain: $f" | |
| step_found=true | |
| # Weaker signal: sysmon alone could be legitimate monitoring | |
| elif grep -qF "sysmon.py" "$f" 2>/dev/null; then | |
| warn "LaunchAgent references sysmon.py (verify manually): $f" | |
| step_found=true | |
| fi | |
| done < <(find "$launchagent_dir" -name "*.plist" -type f -print0 2>/dev/null || true) | |
| fi | |
| # Temp file artifacts | |
| local tmp_dir="${TMPDIR:-/tmp}" | |
| local suspect_files=( | |
| "$tmp_dir/c" "$tmp_dir/pglog" "$tmp_dir/.pg_state" "$tmp_dir/tpcp.tar.gz" | |
| ) | |
| # Also check /tmp if TMPDIR is different | |
| if [[ "$tmp_dir" != "/tmp" ]]; then | |
| suspect_files+=("/tmp/c" "/tmp/pglog" "/tmp/.pg_state" "/tmp/tpcp.tar.gz") | |
| fi | |
| for p in "${suspect_files[@]}"; do | |
| if [[ -f "$p" ]]; then | |
| local size | |
| size=$(wc -c < "$p" 2>/dev/null || echo "?") | |
| found "Temp artifact: $p (${size} bytes)" | |
| step_found=true | |
| fi | |
| done | |
| # Exfil archive | |
| while IFS= read -r -d '' f; do | |
| found "Exfiltration archive: $f" | |
| step_found=true | |
| done < <(find "$HOME" -maxdepth 3 -name "tpcp.tar.gz" -type f -print0 2>/dev/null || true) | |
| [[ "$step_found" == false ]] && clean "No persistence or temp artifacts found" | |
| } | |
| # ── 13. Network + process + Kubernetes IOCs ────────────────────────────────── | |
| check_runtime_iocs() { | |
| section "13/13 Runtime IOCs (network, processes, Kubernetes)" | |
| local step_found=false | |
| # --- Active network connections --- | |
| local ip | |
| if command -v lsof &>/dev/null; then | |
| for ip in "${MALICIOUS_IPS[@]}"; do | |
| if lsof -i "@$ip" -n 2>/dev/null | grep -qF "$ip"; then | |
| found "Active connection to malicious IP: $ip" | |
| step_found=true | |
| fi | |
| done | |
| elif command -v ss &>/dev/null; then | |
| for ip in "${MALICIOUS_IPS[@]}"; do | |
| if ss -tnp 2>/dev/null | grep -qF "$ip"; then | |
| found "Active connection to malicious IP: $ip" | |
| step_found=true | |
| fi | |
| done | |
| elif command -v netstat &>/dev/null; then | |
| for ip in "${MALICIOUS_IPS[@]}"; do | |
| if netstat -an 2>/dev/null | grep -qF "$ip"; then | |
| found "Active connection to malicious IP: $ip" | |
| step_found=true | |
| fi | |
| done | |
| fi | |
| # --- /etc/hosts --- | |
| local domain | |
| for domain in "${MALICIOUS_DOMAINS[@]}"; do | |
| if [[ -f /etc/hosts ]] && grep -qF "$domain" /etc/hosts 2>/dev/null; then | |
| warn "Domain in /etc/hosts: $domain" | |
| step_found=true | |
| fi | |
| done | |
| # --- Shell history --- | |
| local hf | |
| for hf in "$HOME/.zsh_history" "$HOME/.bash_history"; do | |
| [[ -f "$hf" ]] || continue | |
| for domain in "${MALICIOUS_DOMAINS[@]}"; do | |
| if grep -qF "$domain" "$hf" 2>/dev/null; then | |
| warn "Malicious domain in shell history ($hf): $domain" | |
| step_found=true | |
| fi | |
| done | |
| done | |
| # --- Suspicious processes --- | |
| if pgrep -f "sysmon\.py" &>/dev/null; then | |
| found "Process running sysmon.py (backdoor)" | |
| step_found=true | |
| fi | |
| local python_count | |
| python_count=$(pgrep -c python 2>/dev/null || echo "0") | |
| if [[ "$python_count" -gt 100 ]]; then | |
| found "Abnormal Python process count: $python_count (possible fork bomb)" | |
| step_found=true | |
| elif [[ "$python_count" -gt 50 ]]; then | |
| warn "Elevated Python process count: $python_count" | |
| step_found=true | |
| fi | |
| # Check for curl to C2 domains separately (macOS pgrep doesn't support \| alternation) | |
| for domain in "${MALICIOUS_DOMAINS[@]}"; do | |
| # Escape dots for pgrep regex | |
| local escaped_domain="${domain//./\\.}" | |
| if pgrep -af "curl.*$escaped_domain" 2>/dev/null | grep -vq grep; then | |
| found "Active curl to malicious domain: $domain" | |
| step_found=true | |
| fi | |
| done | |
| # --- Kubernetes --- | |
| if command -v kubectl &>/dev/null && kubectl config current-context &>/dev/null 2>&1; then | |
| # Fetch pod JSON once for all K8s checks | |
| local pods_json | |
| pods_json=$(kubectl get pods -n kube-system -o json 2>/dev/null) || true | |
| if [[ -n "$pods_json" ]]; then | |
| local bad_pods | |
| bad_pods=$(echo "$pods_json" | grep -o '"name":"node-setup-[^"]*"' | cut -d'"' -f4) || true | |
| if [[ -n "$bad_pods" ]]; then | |
| found "Suspicious node-setup pods in kube-system" | |
| info "$bad_pods" | |
| step_found=true | |
| fi | |
| # Check for alpine+hostPath pods (use jq if available, fall back to python3) | |
| local alpine_pods="" | |
| if command -v jq &>/dev/null; then | |
| alpine_pods=$(echo "$pods_json" | \ | |
| jq -r '.items[] | select( | |
| (.spec.containers[]?.image | contains("alpine")) and | |
| (.spec.volumes[]? | has("hostPath")) | |
| ) | .metadata.name' 2>/dev/null) || true | |
| elif command -v python3 &>/dev/null; then | |
| alpine_pods=$(echo "$pods_json" | \ | |
| python3 -c " | |
| import json, sys | |
| data = json.load(sys.stdin) | |
| for pod in data.get('items', []): | |
| containers = pod.get('spec', {}).get('containers', []) | |
| volumes = pod.get('spec', {}).get('volumes', []) | |
| has_alpine = any('alpine' in c.get('image', '') for c in containers) | |
| has_hostpath = any('hostPath' in v for v in volumes) | |
| if has_alpine and has_hostpath: | |
| print(pod['metadata']['name']) | |
| " 2>/dev/null) || true | |
| fi | |
| if [[ -n "$alpine_pods" ]]; then | |
| found "Alpine pods with hostPath mounts in kube-system" | |
| info "$alpine_pods" | |
| step_found=true | |
| fi | |
| fi | |
| else | |
| info "No active Kubernetes cluster, skipping K8s checks" | |
| fi | |
| [[ "$step_found" == false ]] && clean "No runtime IOCs found" | |
| } | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| # SUMMARY | |
| # ══════════════════════════════════════════════════════════════════════════════ | |
| summary() { | |
| echo "" | |
| echo -e "${BOLD}════════════════════════════════════════════════════════════════${NC}" | |
| if [[ "$found_count" -gt 0 ]]; then | |
| echo -e "${RED}${BOLD} COMPROMISED: $found_count issue(s), $warn_count warning(s)${NC}" | |
| echo "" | |
| echo -e "${BOLD} Immediate actions:${NC}" | |
| echo " 1. Uninstall: pip uninstall litellm -y (in every affected env)" | |
| echo " 2. Reinstall: pip install litellm==1.82.6" | |
| echo " 3. Caches: pip cache purge && rm -rf ~/.cache/uv/archive-v0/*litellm*" | |
| echo " 4. Persistence: rm -rf ~/.config/sysmon/ ~/.config/systemd/user/sysmon.service" | |
| echo " 5. Temp files: rm -f /tmp/c /tmp/pglog /tmp/.pg_state /tmp/tpcp.tar.gz" | |
| echo " 6. Kubernetes: kubectl delete pods -n kube-system -l app=node-setup" | |
| echo "" | |
| echo -e "${RED}${BOLD} ROTATE ALL CREDENTIALS:${NC}" | |
| echo " - API keys (OpenAI, Anthropic, etc.)" | |
| echo " - SSH keys (~/.ssh/)" | |
| echo " - AWS / GCP / Azure credentials" | |
| echo " - Database passwords" | |
| echo " - Any secrets in .env files or shell history" | |
| echo " - Review connected services for unauthorized access" | |
| echo " - Monitor network for 46.151.182.203 / 83.142.209.11" | |
| elif [[ "$warn_count" -gt 0 ]]; then | |
| echo -e "${YELLOW}${BOLD} WARNINGS: $warn_count warning(s), no confirmed compromises${NC}" | |
| echo "" | |
| echo " Review warnings above. Consider credential rotation as a precaution." | |
| else | |
| echo -e "${GREEN}${BOLD} CLEAN: No indicators of compromise found${NC}" | |
| fi | |
| echo "" | |
| echo -e "${BOLD} References:${NC}" | |
| echo " https://github.com/BerriAI/litellm/issues/24518" | |
| echo " PYSEC-2026-2" | |
| echo -e "${BOLD}════════════════════════════════════════════════════════════════${NC}" | |
| echo "" | |
| } | |
| # ── Main ───────────────────────────────────────────────────────────────────── | |
| main() { | |
| banner | |
| # Part A: Package discovery | |
| enumerate_site_packages | |
| check_pip_global | |
| check_site_packages | |
| check_conda_envs | |
| check_pyenv | |
| check_venvs | |
| check_uv | |
| check_dependency_files | |
| check_caches | |
| check_dist_info | |
| # Part B: Malware artifact detection | |
| check_pth_files | |
| check_persistence | |
| check_runtime_iocs | |
| summary | |
| [[ "$found_count" -gt 0 ]] && exit 1 | |
| exit 0 | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment