Skip to content

Instantly share code, notes, and snippets.

@RitvikDayal
Last active March 28, 2026 06:01
Show Gist options
  • Select an option

  • Save RitvikDayal/18d35fe1d51b49ecf5b90c6f262a8c9d to your computer and use it in GitHub Desktop.

Select an option

Save RitvikDayal/18d35fe1d51b49ecf5b90c6f262a8c9d to your computer and use it in GitHub Desktop.
litellm-sweep: Scan your system for traces of the compromised litellm package (TeamPCP supply chain attack, March 2026). Checks pyenv, virtualenvs, conda, pip cache, Homebrew, source code references, persistence artifacts, network IOCs, and Kubernetes IOCs.
#!/usr/bin/env bash
# litellm-sweep.sh — Find and optionally remove all traces of litellm
# Includes IOC detection for the TeamPCP supply chain attack (March 2026)
# Ref: https://snyk.io/articles/poisoned-security-scanner-backdooring-litellm/
#
# Usage:
# ./litellm-sweep.sh # Full scan (home + common locations)
# ./litellm-sweep.sh --include /opt /srv # Full scan + additional paths
# ./litellm-sweep.sh --only /opt/ml /srv/apps # ONLY scan these paths, skip env scans
set -euo pipefail
# ─── Colors ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
# ─── State ────────────────────────────────────────────────────────────────────
ONLY_MODE=false
INCLUDE_PATHS=()
ONLY_PATHS=()
FINDINGS=()
FINDING_TYPES=() # parallel array: "package" or "reference"
FINDING_CONTEXTS=() # parallel array: removal context (pip path, conda env, etc.)
REPORT_FILE="litellm-sweep-report-$(date +%Y-%m-%d).txt"
# Phase counters
COUNT_PYENV=0
COUNT_VENV=0
COUNT_SYSTEM=0
COUNT_PIP_CACHE=0
COUNT_CONDA=0
COUNT_BREW=0
COUNT_SOURCE=0
COUNT_IOC_PERSIST=0
COUNT_IOC_NETWORK=0
COUNT_IOC_K8S=0
COUNT_IOC_PTH=0
COMPROMISED_FOUND=false
# ─── Known IOCs (TeamPCP / CVE-2026-XXXXX) ──────────────────────────────────
COMPROMISED_VERSIONS=("1.82.7" "1.82.8")
C2_DOMAINS=("models.litellm.cloud" "checkmarx.zone")
PERSISTENCE_FILES=(
"$HOME/.config/sysmon/sysmon.py"
"$HOME/.config/systemd/user/sysmon.service"
"/tmp/tpcp.tar.gz"
"/tmp/session.key"
"/tmp/payload.enc"
)
# ─── Helpers ──────────────────────────────────────────────────────────────────
log_finding() {
local phase="$1"
local message="$2"
local type="${3:-reference}" # "package", "reference", or "ioc"
local context="${4:-}" # removal context
FINDINGS+=("[$phase] $message")
FINDING_TYPES+=("$type")
FINDING_CONTEXTS+=("$context")
echo -e " ${RED}FOUND${RESET} $message"
}
log_critical() {
local phase="$1"
local message="$2"
local type="${3:-ioc}"
local context="${4:-}"
FINDINGS+=("[$phase] $message")
FINDING_TYPES+=("$type")
FINDING_CONTEXTS+=("$context")
echo -e " ${RED}${BOLD}!!CRITICAL!!${RESET} $message"
}
# Check if a version string matches a known compromised version
is_compromised_version() {
local ver="$1"
for bad in "${COMPROMISED_VERSIONS[@]}"; do
if [[ "$ver" == "$bad" ]]; then
return 0
fi
done
return 1
}
log_clean() {
echo -e " ${GREEN}CLEAN${RESET} $1"
}
log_skip() {
echo -e " ${YELLOW}SKIP${RESET} $1"
}
phase_header() {
echo ""
echo -e "${CYAN}${BOLD}── Phase $1: $2 ──${RESET}"
}
# ─── Argument Parsing ─────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--only)
ONLY_MODE=true
shift
while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do
ONLY_PATHS+=("$1")
shift
done
;;
--include)
shift
while [[ $# -gt 0 && ! "$1" =~ ^-- ]]; do
INCLUDE_PATHS+=("$1")
shift
done
;;
--help|-h)
echo "Usage: litellm-sweep.sh [--include PATH...] [--only PATH...]"
echo ""
echo " --include PATH... Add extra paths to the source code scan"
echo " --only PATH... ONLY scan these paths (skip all env scans)"
echo " -h, --help Show this help"
exit 0
;;
*)
echo "Unknown argument: $1"
exit 1
;;
esac
done
if $ONLY_MODE && [[ ${#ONLY_PATHS[@]} -eq 0 ]]; then
echo "Error: --only requires at least one path"
exit 1
fi
# ─── Banner ───────────────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}litellm-sweep${RESET} — scanning for all traces of litellm"
echo -e "Report will be saved to: ${CYAN}$REPORT_FILE${RESET}"
if $ONLY_MODE; then
echo -e "Mode: ${YELLOW}--only${RESET} (scanning specified paths only, skipping env scans)"
printf " Target: %s\n" "${ONLY_PATHS[@]}"
else
echo -e "Mode: ${GREEN}full scan${RESET} (home + common locations + environments)"
if [[ ${#INCLUDE_PATHS[@]} -gt 0 ]]; then
printf " Extra paths: %s\n" "${INCLUDE_PATHS[@]}"
fi
fi
# ─── Phase 1: pyenv ──────────────────────────────────────────────────────────
if ! $ONLY_MODE; then
phase_header 1 "pyenv"
if command -v pyenv &>/dev/null; then
while IFS= read -r version; do
[[ -z "$version" ]] && continue
result=$(PYENV_VERSION="$version" pyenv exec python -c "import litellm; print(litellm.__version__)" 2>/dev/null) || true
if [[ -n "$result" ]]; then
if is_compromised_version "$result"; then
log_critical "pyenv" "Python $version — litellm $result *** COMPROMISED VERSION ***" "ioc" "pyenv:$version"
COMPROMISED_FOUND=true
else
log_finding "pyenv" "Python $version — litellm $result" "package" "pyenv:$version"
fi
COUNT_PYENV=$((COUNT_PYENV + 1))
else
log_clean "Python $version"
fi
done < <(pyenv versions --bare 2>/dev/null)
else
log_skip "pyenv not installed"
fi
fi
# ─── Phase 2: virtualenvs ────────────────────────────────────────────────────
if ! $ONLY_MODE; then
phase_header 2 "virtualenvs"
VENV_SEARCH_ROOTS=("$HOME")
VENV_DIRS=()
# Find .venv / venv directories
while IFS= read -r venv_dir; do
[[ -f "$venv_dir/bin/python" ]] && VENV_DIRS+=("$venv_dir")
done < <(find "${VENV_SEARCH_ROOTS[@]}" -maxdepth 5 \( -name ".venv" -o -name "venv" \) -type d 2>/dev/null || true)
# Poetry virtualenvs
if command -v poetry &>/dev/null; then
poetry_cache="${POETRY_CACHE_DIR:-$HOME/Library/Caches/pypoetry}/virtualenvs"
if [[ -d "$poetry_cache" ]]; then
while IFS= read -r venv_dir; do
[[ -f "$venv_dir/bin/python" ]] && VENV_DIRS+=("$venv_dir")
done < <(find "$poetry_cache" -maxdepth 1 -type d 2>/dev/null || true)
fi
fi
# Pipenv virtualenvs
pipenv_home="${WORKON_HOME:-$HOME/.local/share/virtualenvs}"
if [[ -d "$pipenv_home" ]]; then
while IFS= read -r venv_dir; do
[[ -f "$venv_dir/bin/python" ]] && VENV_DIRS+=("$venv_dir")
done < <(find "$pipenv_home" -maxdepth 1 -type d 2>/dev/null || true)
fi
# ~/.virtualenvs (virtualenvwrapper)
if [[ -d "$HOME/.virtualenvs" ]]; then
while IFS= read -r venv_dir; do
[[ -f "$venv_dir/bin/python" ]] && VENV_DIRS+=("$venv_dir")
done < <(find "$HOME/.virtualenvs" -maxdepth 1 -type d 2>/dev/null || true)
fi
if [[ ${#VENV_DIRS[@]} -eq 0 ]]; then
log_clean "No virtualenvs found"
else
# Deduplicate
mapfile -t VENV_DIRS < <(printf '%s\n' "${VENV_DIRS[@]}" | sort -u)
for venv_dir in "${VENV_DIRS[@]}"; do
pip_bin="$venv_dir/bin/pip"
if [[ -x "$pip_bin" ]]; then
result=$("$pip_bin" show litellm 2>/dev/null | grep -i "^Version:" | awk '{print $2}') || true
if [[ -n "$result" ]]; then
if is_compromised_version "$result"; then
log_critical "venv" "$venv_dir — litellm $result *** COMPROMISED VERSION ***" "ioc" "venv:$venv_dir"
COMPROMISED_FOUND=true
else
log_finding "venv" "$venv_dir — litellm $result" "package" "venv:$venv_dir"
fi
COUNT_VENV=$((COUNT_VENV + 1))
else
log_clean "$venv_dir"
fi
fi
done
fi
fi
# ─── Phase 3: System Python ──────────────────────────────────────────────────
if ! $ONLY_MODE; then
phase_header 3 "System Python"
SYSTEM_PYTHONS=(
/usr/bin/python3
/usr/local/bin/python3
)
# Homebrew python
if command -v brew &>/dev/null; then
brew_python="$(brew --prefix 2>/dev/null)/bin/python3"
[[ -x "$brew_python" ]] && SYSTEM_PYTHONS+=("$brew_python")
fi
# Deduplicate by resolved path
declare -A SEEN_PYTHONS
for py in "${SYSTEM_PYTHONS[@]}"; do
[[ ! -x "$py" ]] && continue
resolved=$(readlink -f "$py" 2>/dev/null || realpath "$py" 2>/dev/null || echo "$py")
if [[ -n "${SEEN_PYTHONS[$resolved]:-}" ]]; then
continue
fi
SEEN_PYTHONS["$resolved"]=1
result=$("$py" -c "import litellm; print(litellm.__version__)" 2>/dev/null) || true
if [[ -n "$result" ]]; then
if is_compromised_version "$result"; then
log_critical "system" "$py — litellm $result *** COMPROMISED VERSION ***" "ioc" "system:$py"
COMPROMISED_FOUND=true
else
log_finding "system" "$py — litellm $result" "package" "system:$py"
fi
COUNT_SYSTEM=$((COUNT_SYSTEM + 1))
else
log_clean "$py"
fi
done
fi
# ─── Phase 4: pip cache ──────────────────────────────────────────────────────
if ! $ONLY_MODE; then
phase_header 4 "pip cache"
# Try pip cache command
pip_cache_output=$(pip cache list litellm 2>/dev/null) || true
if [[ -n "$pip_cache_output" ]]; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
log_finding "pip-cache" "$line" "package" "pip-cache:cli"
COUNT_PIP_CACHE=$((COUNT_PIP_CACHE + 1))
done <<< "$pip_cache_output"
fi
# Also check cache dirs directly
PIP_CACHE_DIRS=(
"$HOME/.cache/pip"
"$HOME/Library/Caches/pip"
)
for cache_dir in "${PIP_CACHE_DIRS[@]}"; do
[[ ! -d "$cache_dir" ]] && continue
while IFS= read -r cached_file; do
[[ -z "$cached_file" ]] && continue
log_finding "pip-cache" "Cached file: $cached_file" "package" "pip-cache:file:$cached_file"
COUNT_PIP_CACHE=$((COUNT_PIP_CACHE + 1))
done < <(find "$cache_dir" -iname "*litellm*" -type f 2>/dev/null || true)
done
if [[ $COUNT_PIP_CACHE -eq 0 ]]; then
log_clean "No litellm in pip cache"
fi
fi
# ─── Phase 5: Conda ──────────────────────────────────────────────────────────
if ! $ONLY_MODE; then
phase_header 5 "Conda"
if command -v conda &>/dev/null; then
while IFS= read -r env_path; do
[[ -z "$env_path" ]] && continue
[[ "$env_path" == "#"* ]] && continue
env_name=$(basename "$env_path")
py_bin="$env_path/bin/python"
[[ ! -x "$py_bin" ]] && continue
result=$("$py_bin" -c "import litellm; print(litellm.__version__)" 2>/dev/null) || true
if [[ -n "$result" ]]; then
if is_compromised_version "$result"; then
log_critical "conda" "Env '$env_name' ($env_path) — litellm $result *** COMPROMISED VERSION ***" "ioc" "conda:$env_name"
COMPROMISED_FOUND=true
else
log_finding "conda" "Env '$env_name' ($env_path) — litellm $result" "package" "conda:$env_name"
fi
COUNT_CONDA=$((COUNT_CONDA + 1))
else
log_clean "Env '$env_name'"
fi
done < <(conda env list 2>/dev/null | grep -v "^#" | awk '{print $NF}' | grep -v "^$" || true)
else
log_skip "conda not installed"
fi
fi
# ─── Phase 6: Homebrew ───────────────────────────────────────────────────────
if ! $ONLY_MODE; then
phase_header 6 "Homebrew"
if command -v brew &>/dev/null; then
if brew list --formula 2>/dev/null | grep -qi litellm; then
log_finding "brew" "litellm installed via Homebrew" "package" "brew:litellm"
COUNT_BREW=$((COUNT_BREW + 1))
else
log_clean "Not in Homebrew formulae"
fi
# Check casks too
if brew list --cask 2>/dev/null | grep -qi litellm; then
log_finding "brew" "litellm installed as Homebrew cask" "package" "brew-cask:litellm"
COUNT_BREW=$((COUNT_BREW + 1))
fi
else
log_skip "Homebrew not installed"
fi
fi
# ─── Phase 7: Source code scan ────────────────────────────────────────────────
phase_header 7 "Source code references"
SOURCE_PATTERNS=(
"*.py"
"requirements*.txt"
"pyproject.toml"
"Pipfile"
"Pipfile.lock"
"setup.cfg"
"setup.py"
"*.toml"
"*.yaml"
"*.yml"
"*.cfg"
"*.ini"
"*.json"
"*.conf"
"Dockerfile*"
"docker-compose*.yml"
"docker-compose*.yaml"
"Makefile"
"tox.ini"
)
SEARCH_PATHS=()
if $ONLY_MODE; then
SEARCH_PATHS=("${ONLY_PATHS[@]}")
else
SEARCH_PATHS=(
"$HOME"
"/usr/local/lib"
"/usr/local/etc"
"/etc"
)
if [[ ${#INCLUDE_PATHS[@]} -gt 0 ]]; then
SEARCH_PATHS+=("${INCLUDE_PATHS[@]}")
fi
fi
# Build grep include args
GREP_INCLUDES=()
for pat in "${SOURCE_PATTERNS[@]}"; do
GREP_INCLUDES+=(--include="$pat")
done
# Directories to skip
GREP_EXCLUDES=(
--exclude-dir=".git"
--exclude-dir="node_modules"
--exclude-dir="__pycache__"
--exclude-dir=".tox"
--exclude-dir=".mypy_cache"
--exclude-dir=".pytest_cache"
--exclude-dir="*.egg-info"
)
for search_path in "${SEARCH_PATHS[@]}"; do
[[ ! -d "$search_path" ]] && {
log_skip "Path not found: $search_path"
continue
}
echo -e " Scanning ${CYAN}$search_path${RESET} ..."
while IFS= read -r match; do
[[ -z "$match" ]] && continue
# Extract file path (everything before the first colon)
file_path="${match%%:*}"
log_finding "source" "$match" "reference" "source:$file_path"
COUNT_SOURCE=$((COUNT_SOURCE + 1))
done < <(grep -rn "${GREP_INCLUDES[@]}" "${GREP_EXCLUDES[@]}" -i "litellm" "$search_path" 2>/dev/null || true)
done
if [[ $COUNT_SOURCE -eq 0 ]]; then
log_clean "No source code references found"
fi
# ─── Phase 8: Persistence artifacts (TeamPCP IOCs) ───────────────────────────
phase_header 8 "Persistence artifacts (TeamPCP backdoor)"
for artifact in "${PERSISTENCE_FILES[@]}"; do
if [[ -e "$artifact" ]]; then
log_critical "ioc-persist" "BACKDOOR ARTIFACT: $artifact" "ioc" "ioc-file:$artifact"
COUNT_IOC_PERSIST=$((COUNT_IOC_PERSIST + 1))
else
log_clean "$artifact"
fi
done
# Check for sysmon systemd service (running or enabled)
if command -v systemctl &>/dev/null; then
if systemctl --user is-active sysmon.service &>/dev/null; then
log_critical "ioc-persist" "sysmon.service is ACTIVELY RUNNING" "ioc" "ioc-service:active"
COUNT_IOC_PERSIST=$((COUNT_IOC_PERSIST + 1))
elif systemctl --user is-enabled sysmon.service &>/dev/null; then
log_critical "ioc-persist" "sysmon.service is ENABLED (not running)" "ioc" "ioc-service:enabled"
COUNT_IOC_PERSIST=$((COUNT_IOC_PERSIST + 1))
else
log_clean "sysmon.service not registered"
fi
fi
# Check for launchd persistence on macOS
if [[ "$(uname)" == "Darwin" ]]; then
for plist_dir in "$HOME/Library/LaunchAgents" "/Library/LaunchAgents" "/Library/LaunchDaemons"; do
if [[ -d "$plist_dir" ]]; then
while IFS= read -r plist; do
[[ -z "$plist" ]] && continue
log_critical "ioc-persist" "Suspicious plist referencing sysmon: $plist" "ioc" "ioc-file:$plist"
COUNT_IOC_PERSIST=$((COUNT_IOC_PERSIST + 1))
done < <(grep -rl "sysmon" "$plist_dir" 2>/dev/null || true)
fi
done
fi
# Check for litellm_init.pth files (v1.82.8 persistence mechanism)
echo -e " Scanning for ${BOLD}litellm_init.pth${RESET} files (v1.82.8 startup hook)..."
while IFS= read -r pth_file; do
[[ -z "$pth_file" ]] && continue
log_critical "ioc-pth" "MALICIOUS .pth FILE: $pth_file" "ioc" "ioc-file:$pth_file"
COUNT_IOC_PTH=$((COUNT_IOC_PTH + 1))
done < <(find / -name "litellm_init.pth" -type f 2>/dev/null || true)
# Also check for any .pth files mentioning litellm in site-packages
while IFS= read -r pth_file; do
[[ -z "$pth_file" ]] && continue
if grep -qi "litellm\|tpcp\|sysmon" "$pth_file" 2>/dev/null; then
log_critical "ioc-pth" "Suspicious .pth file with litellm/backdoor reference: $pth_file" "ioc" "ioc-file:$pth_file"
COUNT_IOC_PTH=$((COUNT_IOC_PTH + 1))
fi
done < <(find / -path "*/site-packages/*.pth" -type f 2>/dev/null || true)
if [[ $COUNT_IOC_PERSIST -eq 0 && $COUNT_IOC_PTH -eq 0 ]]; then
log_clean "No persistence artifacts found"
fi
# ─── Phase 9: Network IOCs (C2 domains) ─────────────────────────────────────
phase_header 9 "Network IOCs (C2 domains)"
for domain in "${C2_DOMAINS[@]}"; do
# Check /etc/hosts
if grep -qi "$domain" /etc/hosts 2>/dev/null; then
log_critical "ioc-network" "C2 domain in /etc/hosts: $domain" "ioc" "ioc-hosts:$domain"
COUNT_IOC_NETWORK=$((COUNT_IOC_NETWORK + 1))
fi
# Check DNS resolution (does the domain resolve from this machine?)
if host "$domain" &>/dev/null || nslookup "$domain" &>/dev/null 2>&1; then
log_finding "ioc-network" "C2 domain resolves: $domain (verify no active connections)" "reference" "ioc-dns:$domain"
COUNT_IOC_NETWORK=$((COUNT_IOC_NETWORK + 1))
fi
# Check active connections (macOS: lsof, Linux: ss/netstat)
if lsof -i -n -P 2>/dev/null | grep -qi "$domain"; then
log_critical "ioc-network" "ACTIVE CONNECTION to C2 domain: $domain" "ioc" "ioc-conn:$domain"
COUNT_IOC_NETWORK=$((COUNT_IOC_NETWORK + 1))
fi
# Check shell history for curl/wget to C2
for hist_file in "$HOME/.bash_history" "$HOME/.zsh_history" "$HOME/.local/share/fish/fish_history"; do
if [[ -f "$hist_file" ]] && grep -qi "$domain" "$hist_file" 2>/dev/null; then
log_finding "ioc-network" "C2 domain found in shell history: $hist_file → $domain" "reference" "ioc-history:$hist_file"
COUNT_IOC_NETWORK=$((COUNT_IOC_NETWORK + 1))
fi
done
done
# Check for the exfiltration archive
if [[ -f "/tmp/tpcp.tar.gz" ]]; then
tpcp_size=$(stat -f%z "/tmp/tpcp.tar.gz" 2>/dev/null || stat -c%s "/tmp/tpcp.tar.gz" 2>/dev/null || echo "unknown")
log_critical "ioc-network" "EXFILTRATION ARCHIVE found: /tmp/tpcp.tar.gz (size: $tpcp_size bytes)" "ioc" "ioc-file:/tmp/tpcp.tar.gz"
COUNT_IOC_NETWORK=$((COUNT_IOC_NETWORK + 1))
fi
if [[ $COUNT_IOC_NETWORK -eq 0 ]]; then
log_clean "No network IOCs found"
fi
# ─── Phase 10: Kubernetes IOCs ───────────────────────────────────────────────
phase_header 10 "Kubernetes IOCs"
if command -v kubectl &>/dev/null; then
# Check for malicious node-setup-* pods
malicious_pods=$(kubectl get pods -n kube-system -o name 2>/dev/null | grep "node-setup-" || true)
if [[ -n "$malicious_pods" ]]; then
while IFS= read -r pod; do
[[ -z "$pod" ]] && continue
log_critical "ioc-k8s" "MALICIOUS POD in kube-system: $pod" "ioc" "ioc-k8s:$pod"
COUNT_IOC_K8S=$((COUNT_IOC_K8S + 1))
done <<< "$malicious_pods"
else
log_clean "No node-setup-* pods in kube-system"
fi
# Check for privileged pods that shouldn't be there
priv_pods=$(kubectl get pods -n kube-system -o json 2>/dev/null | \
python3 -c "
import json, sys
try:
data = json.load(sys.stdin)
for pod in data.get('items', []):
name = pod['metadata']['name']
if not name.startswith('node-setup-'):
continue
for c in pod['spec'].get('containers', []):
sc = c.get('securityContext', {})
if sc.get('privileged'):
print(f'{name} (privileged, image: {c.get(\"image\", \"unknown\")})')
except: pass
" 2>/dev/null || true)
if [[ -n "$priv_pods" ]]; then
while IFS= read -r pod_info; do
[[ -z "$pod_info" ]] && continue
log_critical "ioc-k8s" "PRIVILEGED malicious pod: $pod_info" "ioc" "ioc-k8s-priv:$pod_info"
COUNT_IOC_K8S=$((COUNT_IOC_K8S + 1))
done <<< "$priv_pods"
fi
# Check for unusual secrets access
echo -e " ${YELLOW}TIP:${RESET} Run 'kubectl get events -n kube-system | grep -i secret' to audit secret access"
else
log_skip "kubectl not available (skip k8s IOC check)"
fi
if [[ $COUNT_IOC_K8S -eq 0 ]] && command -v kubectl &>/dev/null; then
log_clean "No Kubernetes IOCs found"
fi
# ─── Summary ──────────────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}══════════════════════════════════════════${RESET}"
echo -e "${BOLD} SCAN SUMMARY${RESET}"
echo -e "${BOLD}══════════════════════════════════════════${RESET}"
TOTAL=${#FINDINGS[@]}
if ! $ONLY_MODE; then
printf " %-25s %s\n" "pyenv:" "$COUNT_PYENV finding(s)"
printf " %-25s %s\n" "virtualenvs:" "$COUNT_VENV finding(s)"
printf " %-25s %s\n" "System Python:" "$COUNT_SYSTEM finding(s)"
printf " %-25s %s\n" "pip cache:" "$COUNT_PIP_CACHE finding(s)"
printf " %-25s %s\n" "Conda:" "$COUNT_CONDA finding(s)"
printf " %-25s %s\n" "Homebrew:" "$COUNT_BREW finding(s)"
fi
printf " %-25s %s\n" "Source references:" "$COUNT_SOURCE finding(s)"
printf " %-25s %s\n" "Persistence artifacts:" "$COUNT_IOC_PERSIST finding(s)"
printf " %-25s %s\n" "Malicious .pth files:" "$COUNT_IOC_PTH finding(s)"
printf " %-25s %s\n" "Network IOCs:" "$COUNT_IOC_NETWORK finding(s)"
printf " %-25s %s\n" "Kubernetes IOCs:" "$COUNT_IOC_K8S finding(s)"
echo -e " ${BOLD}────────────────────────────────────${RESET}"
if [[ $TOTAL -eq 0 ]]; then
echo -e " ${GREEN}${BOLD}TOTAL: 0 findings — system is clean${RESET}"
else
echo -e " ${RED}${BOLD}TOTAL: $TOTAL finding(s)${RESET}"
fi
# ─── Compromised version alert ───────────────────────────────────────────────
if $COMPROMISED_FOUND; then
echo ""
echo -e "${RED}${BOLD}┌─────────────────────────────────────────────────────────────────┐${RESET}"
echo -e "${RED}${BOLD}│ !! COMPROMISED VERSION (1.82.7 or 1.82.8) DETECTED !! │${RESET}"
echo -e "${RED}${BOLD}│ │${RESET}"
echo -e "${RED}${BOLD}│ This system may have been backdoored by TeamPCP. │${RESET}"
echo -e "${RED}${BOLD}│ IMMEDIATE ACTIONS REQUIRED: │${RESET}"
echo -e "${RED}${BOLD}│ │${RESET}"
echo -e "${RED}${BOLD}│ 1. Rotate ALL credentials on this machine: │${RESET}"
echo -e "${RED}${BOLD}│ - SSH keys (~/.ssh/*) │${RESET}"
echo -e "${RED}${BOLD}│ - AWS/GCP/Azure credentials │${RESET}"
echo -e "${RED}${BOLD}│ - Kubernetes tokens & configs │${RESET}"
echo -e "${RED}${BOLD}│ - Docker registry credentials │${RESET}"
echo -e "${RED}${BOLD}│ - API keys, database passwords │${RESET}"
echo -e "${RED}${BOLD}│ - Cryptocurrency wallet keys │${RESET}"
echo -e "${RED}${BOLD}│ │${RESET}"
echo -e "${RED}${BOLD}│ 2. Check for persistence: │${RESET}"
echo -e "${RED}${BOLD}│ - ~/.config/sysmon/sysmon.py │${RESET}"
echo -e "${RED}${BOLD}│ - ~/.config/systemd/user/sysmon.service │${RESET}"
echo -e "${RED}${BOLD}│ - litellm_init.pth in site-packages │${RESET}"
echo -e "${RED}${BOLD}│ │${RESET}"
echo -e "${RED}${BOLD}│ 3. Audit cloud services for unauthorized access │${RESET}"
echo -e "${RED}${BOLD}│ 4. Check k8s for node-setup-* pods in kube-system │${RESET}"
echo -e "${RED}${BOLD}│ 5. Consider rebuilding from a clean environment │${RESET}"
echo -e "${RED}${BOLD}│ │${RESET}"
echo -e "${RED}${BOLD}│ Ref: snyk.io/articles/poisoned-security-scanner-backdooring-litellm/${RESET}"
echo -e "${RED}${BOLD}└─────────────────────────────────────────────────────────────────┘${RESET}"
fi
# ─── Write report file ───────────────────────────────────────────────────────
{
echo "litellm-sweep report — $(date)"
echo "========================================"
echo ""
if [[ $TOTAL -eq 0 ]]; then
echo "No litellm traces found. System is clean."
else
echo "Findings ($TOTAL total):"
echo ""
for i in "${!FINDINGS[@]}"; do
echo " [$((i+1))] ${FINDINGS[$i]}"
echo " Type: ${FINDING_TYPES[$i]}"
done
fi
echo ""
echo "Summary:"
if ! $ONLY_MODE; then
echo " pyenv: $COUNT_PYENV"
echo " virtualenvs: $COUNT_VENV"
echo " System Python: $COUNT_SYSTEM"
echo " pip cache: $COUNT_PIP_CACHE"
echo " Conda: $COUNT_CONDA"
echo " Homebrew: $COUNT_BREW"
fi
echo " Source refs: $COUNT_SOURCE"
echo " Persistence artifacts: $COUNT_IOC_PERSIST"
echo " Malicious .pth files: $COUNT_IOC_PTH"
echo " Network IOCs: $COUNT_IOC_NETWORK"
echo " Kubernetes IOCs: $COUNT_IOC_K8S"
echo " TOTAL: $TOTAL"
echo ""
if $COMPROMISED_FOUND; then
echo "!!! COMPROMISED VERSION DETECTED !!!"
echo "Versions 1.82.7 and 1.82.8 contain a backdoor by threat actor TeamPCP."
echo "See: https://snyk.io/articles/poisoned-security-scanner-backdooring-litellm/"
echo ""
echo "Immediate actions:"
echo " 1. Rotate ALL credentials (SSH, cloud, k8s, Docker, API keys, DB passwords)"
echo " 2. Remove persistence artifacts listed above"
echo " 3. Audit cloud services for unauthorized access"
echo " 4. Check k8s for node-setup-* pods in kube-system namespace"
echo " 5. Rebuild environment from scratch (do not upgrade in place)"
fi
} > "$REPORT_FILE"
echo -e " Report saved to: ${CYAN}$REPORT_FILE${RESET}"
# ─── Interactive Removal ──────────────────────────────────────────────────────
if [[ $TOTAL -eq 0 ]]; then
echo ""
echo -e "${GREEN}Nothing to remove. Done.${RESET}"
exit 0
fi
# Separate packages, IOCs, and source references
PACKAGE_INDICES=()
IOC_INDICES=()
REFERENCE_INDICES=()
for i in "${!FINDINGS[@]}"; do
if [[ "${FINDING_TYPES[$i]}" == "package" ]]; then
PACKAGE_INDICES+=("$i")
elif [[ "${FINDING_TYPES[$i]}" == "ioc" ]]; then
IOC_INDICES+=("$i")
else
REFERENCE_INDICES+=("$i")
fi
done
echo ""
echo -e "${BOLD}── Removal ──${RESET}"
# Handle IOC artifacts first (most critical)
if [[ ${#IOC_INDICES[@]} -gt 0 ]]; then
echo ""
echo -e "${RED}${BOLD}IOC artifacts found (${#IOC_INDICES[@]}):${RESET}"
for idx in "${!IOC_INDICES[@]}"; do
i="${IOC_INDICES[$idx]}"
echo -e " [${BOLD}$((idx+1))${RESET}] ${FINDINGS[$i]}"
done
echo ""
echo -e "${YELLOW}Removable IOC artifacts:${RESET}"
for idx in "${!IOC_INDICES[@]}"; do
i="${IOC_INDICES[$idx]}"
ctx="${FINDING_CONTEXTS[$i]}"
case "$ctx" in
ioc-file:*)
file="${ctx#ioc-file:}"
echo -e " ${RED}$file${RESET}"
;;
ioc-service:*)
echo -e " ${RED}sysmon.service (systemd user service)${RESET}"
;;
esac
done
echo ""
read -rp "Remove IOC artifacts? (y/n) " ioc_confirm
if [[ "$ioc_confirm" == "y" ]]; then
for idx in "${!IOC_INDICES[@]}"; do
i="${IOC_INDICES[$idx]}"
ctx="${FINDING_CONTEXTS[$i]}"
case "$ctx" in
ioc-file:*)
file="${ctx#ioc-file:}"
if [[ -e "$file" ]]; then
echo -e " Removing: $file"
rm -f "$file"
echo -e " ${GREEN}Removed${RESET}"
fi
;;
ioc-service:active)
echo -e " Stopping and disabling sysmon.service..."
systemctl --user stop sysmon.service 2>/dev/null || true
systemctl --user disable sysmon.service 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/sysmon.service" 2>/dev/null || true
systemctl --user daemon-reload 2>/dev/null || true
echo -e " ${GREEN}Stopped + disabled + removed${RESET}"
;;
ioc-service:enabled)
echo -e " Disabling sysmon.service..."
systemctl --user disable sysmon.service 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/sysmon.service" 2>/dev/null || true
systemctl --user daemon-reload 2>/dev/null || true
echo -e " ${GREEN}Disabled + removed${RESET}"
;;
# k8s and network IOCs are reported only — too dangerous to auto-remediate
ioc-k8s:*|ioc-k8s-priv:*|ioc-dns:*|ioc-conn:*|ioc-hosts:*|ioc-history:*)
echo -e " ${YELLOW}${FINDINGS[$i]} — requires manual remediation${RESET}"
;;
esac
done
# Clean up empty sysmon directory
rmdir "$HOME/.config/sysmon" 2>/dev/null || true
else
echo -e " ${YELLOW}Skipped IOC removal${RESET}"
fi
fi
if [[ ${#REFERENCE_INDICES[@]} -gt 0 ]]; then
echo ""
echo -e "${YELLOW}Note:${RESET} ${#REFERENCE_INDICES[@]} source code / informational reference(s) found."
echo -e " These are ${BOLD}reported only${RESET} — you must review/remove them manually."
echo -e " Files with references:"
for i in "${REFERENCE_INDICES[@]}"; do
ctx="${FINDING_CONTEXTS[$i]}"
file="${ctx#source:}"
echo -e " ${YELLOW}$file${RESET}"
done
fi
if [[ ${#PACKAGE_INDICES[@]} -eq 0 ]]; then
echo ""
echo "No removable packages found."
echo -e "${GREEN}Done.${RESET}"
exit 0
fi
echo ""
echo "Removable packages:"
for idx in "${!PACKAGE_INDICES[@]}"; do
i="${PACKAGE_INDICES[$idx]}"
echo -e " [${BOLD}$((idx+1))${RESET}] ${FINDINGS[$i]}"
done
echo ""
echo -e "Options:"
echo -e " ${BOLD}a${RESET} — Remove all packages"
echo -e " ${BOLD}1,3,5${RESET} — Remove specific items (comma-separated)"
echo -e " ${BOLD}s${RESET} — Skip (do nothing)"
echo ""
read -rp "Choice: " removal_choice
if [[ "$removal_choice" == "s" || -z "$removal_choice" ]]; then
echo "Skipped. No changes made."
exit 0
fi
# Determine which indices to remove
REMOVE_INDICES=()
if [[ "$removal_choice" == "a" ]]; then
REMOVE_INDICES=("${!PACKAGE_INDICES[@]}")
else
IFS=',' read -ra selections <<< "$removal_choice"
for sel in "${selections[@]}"; do
sel=$(echo "$sel" | tr -d ' ')
if [[ "$sel" =~ ^[0-9]+$ ]] && [[ $sel -ge 1 ]] && [[ $sel -le ${#PACKAGE_INDICES[@]} ]]; then
REMOVE_INDICES+=("$((sel - 1))")
else
echo "Invalid selection: $sel (skipping)"
fi
done
fi
# Execute removals
for idx in "${REMOVE_INDICES[@]}"; do
i="${PACKAGE_INDICES[$idx]}"
ctx="${FINDING_CONTEXTS[$i]}"
echo ""
echo -e "Removing: ${FINDINGS[$i]}"
case "$ctx" in
pyenv:*)
version="${ctx#pyenv:}"
echo -e " Action: PYENV_VERSION=$version pyenv exec pip uninstall -y litellm"
read -rp " Confirm? (y/n) " confirm
if [[ "$confirm" == "y" ]]; then
PYENV_VERSION="$version" pyenv exec pip uninstall -y litellm 2>&1 | sed 's/^/ /'
echo -e " ${GREEN}Removed${RESET}"
else
echo -e " ${YELLOW}Skipped${RESET}"
fi
;;
venv:*)
venv_dir="${ctx#venv:}"
pip_bin="$venv_dir/bin/pip"
echo -e " Action: $pip_bin uninstall -y litellm"
read -rp " Confirm? (y/n) " confirm
if [[ "$confirm" == "y" ]]; then
"$pip_bin" uninstall -y litellm 2>&1 | sed 's/^/ /'
echo -e " ${GREEN}Removed${RESET}"
else
echo -e " ${YELLOW}Skipped${RESET}"
fi
;;
system:*)
py_path="${ctx#system:}"
pip_cmd="${py_path%python3}pip3"
if [[ ! -x "$pip_cmd" ]]; then
pip_cmd="$py_path -m pip"
fi
echo -e " Action: $pip_cmd uninstall -y litellm"
echo -e " ${YELLOW}(may require sudo)${RESET}"
read -rp " Confirm? (y/n) " confirm
if [[ "$confirm" == "y" ]]; then
$pip_cmd uninstall -y litellm 2>&1 | sed 's/^/ /' || \
sudo $pip_cmd uninstall -y litellm 2>&1 | sed 's/^/ /'
echo -e " ${GREEN}Removed${RESET}"
else
echo -e " ${YELLOW}Skipped${RESET}"
fi
;;
pip-cache:cli)
echo -e " Action: pip cache remove litellm"
read -rp " Confirm? (y/n) " confirm
if [[ "$confirm" == "y" ]]; then
pip cache remove litellm 2>&1 | sed 's/^/ /'
echo -e " ${GREEN}Removed${RESET}"
else
echo -e " ${YELLOW}Skipped${RESET}"
fi
;;
pip-cache:file:*)
file_path="${ctx#pip-cache:file:}"
echo -e " Action: rm \"$file_path\""
read -rp " Confirm? (y/n) " confirm
if [[ "$confirm" == "y" ]]; then
rm -f "$file_path"
echo -e " ${GREEN}Removed${RESET}"
else
echo -e " ${YELLOW}Skipped${RESET}"
fi
;;
conda:*)
env_name="${ctx#conda:}"
echo -e " Action: conda remove -n $env_name litellm -y"
read -rp " Confirm? (y/n) " confirm
if [[ "$confirm" == "y" ]]; then
conda remove -n "$env_name" litellm -y 2>&1 | sed 's/^/ /'
echo -e " ${GREEN}Removed${RESET}"
else
echo -e " ${YELLOW}Skipped${RESET}"
fi
;;
brew:*)
echo -e " Action: brew uninstall litellm"
read -rp " Confirm? (y/n) " confirm
if [[ "$confirm" == "y" ]]; then
brew uninstall litellm 2>&1 | sed 's/^/ /'
echo -e " ${GREEN}Removed${RESET}"
else
echo -e " ${YELLOW}Skipped${RESET}"
fi
;;
brew-cask:*)
echo -e " Action: brew uninstall --cask litellm"
read -rp " Confirm? (y/n) " confirm
if [[ "$confirm" == "y" ]]; then
brew uninstall --cask litellm 2>&1 | sed 's/^/ /'
echo -e " ${GREEN}Removed${RESET}"
else
echo -e " ${YELLOW}Skipped${RESET}"
fi
;;
*)
echo -e " ${YELLOW}Unknown context: $ctx — skipping${RESET}"
;;
esac
done
echo ""
echo -e "${BOLD}── Done ──${RESET}"
echo -e "Review source code references manually and remove litellm from your dependency files."
if $COMPROMISED_FOUND; then
echo ""
echo -e "${RED}${BOLD}REMINDER: Compromised version was detected. Credential rotation is CRITICAL.${RESET}"
echo -e "${RED}Do NOT just uninstall — assume all secrets on this machine are compromised.${RESET}"
echo -e "${RED}Rebuild from a clean environment after rotating all credentials.${RESET}"
fi
echo -e "Report: ${CYAN}$REPORT_FILE${RESET}"

litellm-sweep

Scan your entire system for traces of the compromised litellm Python package.

Built in response to the TeamPCP supply chain attack (March 24, 2026) where versions 1.82.7 and 1.82.8 were published to PyPI with a backdoor that harvests credentials, establishes persistence, and exfiltrates data to a C2 server.

Reference: https://snyk.io/articles/poisoned-security-scanner-backdooring-litellm/

Quick Start

curl -O https://gist.githubusercontent.com/RitvikDayal/18d35fe1d51b49ecf5b90c6f262a8c9d/raw/litellm-sweep.sh
chmod +x litellm-sweep.sh
./litellm-sweep.sh

Usage

litellm-sweep.sh [--include PATH...] [--only PATH...] [-h|--help]

Flags

Flag Description
(no flags) Full scan: all Python environments + home directory + common system paths
--include PATH... Full scan + additional paths added to the source code scan
--only PATH... Skip all environment scans, only scan the specified paths
-h, --help Show help and exit

Examples

# Full scan (pyenv, venvs, conda, system python, pip cache, homebrew, source code, IOCs)
./litellm-sweep.sh

# Full scan + also check /opt/ml and /srv/apps
./litellm-sweep.sh --include /opt/ml /srv/apps

# ONLY scan these directories (skip pyenv/venv/conda/system/brew scans)
./litellm-sweep.sh --only /srv/apps/inference /srv/apps/ml-service

What It Scans (10 Phases)

Environment Scans (skipped with --only)

Phase What How
1. pyenv All pyenv-managed Python versions import litellm per version
2. virtualenvs .venv, venv, poetry, pipenv, virtualenvwrapper pip show litellm per env
3. System Python /usr/bin/python3, /usr/local/bin/python3, Homebrew python import litellm
4. pip cache Cached wheels and downloads pip cache list + direct file scan
5. Conda All conda environments import litellm per env
6. Homebrew Formulae and casks brew list

Source Code Scan

Phase What How
7. Source refs *.py, requirements*.txt, pyproject.toml, Pipfile, Dockerfile, *.yaml, *.yml, *.json, *.toml, Makefile, tox.ini, etc. grep -rn across search paths

IOC Detection (always runs)

Phase What How
8. Persistence ~/.config/sysmon/sysmon.py, sysmon.service (systemd + macOS LaunchAgents), litellm_init.pth in site-packages, /tmp/tpcp.tar.gz, /tmp/session.key, /tmp/payload.enc File existence + systemctl status
9. Network C2 domains (models.litellm.cloud, checkmarx.zone): active connections, /etc/hosts, shell history lsof, grep
10. Kubernetes node-setup-* pods in kube-system, privileged container detection kubectl get pods

Compromised Versions

Versions 1.82.7 and 1.82.8 are known compromised. The script flags these with !!CRITICAL!! and displays a remediation alert.

What the backdoor does

  1. Harvests SSH keys, cloud credentials (AWS/GCP/Azure), Kubernetes configs, Docker registry tokens, CI/CD secrets, crypto wallet keys, password hashes
  2. Encrypts and exfiltrates data (AES-256 + RSA-4096) to models.litellm.cloud
  3. Persists via systemd service at ~/.config/sysmon/. In Kubernetes, deploys privileged pods named node-setup-{node} across all cluster nodes

Version 1.82.8 additionally drops litellm_init.pth into site-packages, which executes on every Python interpreter launch even without importing litellm.

Interactive Removal

After scanning, the script groups findings into three categories:

  1. IOC artifacts (prompted first, most critical): persistence files, systemd services, .pth files. Handles systemctl stop/disable and file removal.
  2. Installed packages: numbered list with options to remove all, select specific ones, or skip. Each removal shows the exact command and confirms before running.
  3. Source code references: reported only, never auto-edited.

Kubernetes pods and network connections are flagged but require manual remediation.

Output

Terminal output is color coded:

  • CLEAN (green) for items that passed
  • FOUND (red) for findings
  • !!CRITICAL!! (bold red) for compromised versions and active backdoor artifacts

A text report is saved to litellm-sweep-report-YYYY-MM-DD.txt in the working directory.

If a Compromised Version Is Found

Do not just uninstall and move on. The exfiltration happens immediately on first execution.

  1. Rotate ALL credentials on the affected machine: SSH keys, cloud creds, k8s tokens, Docker creds, API keys, DB passwords, crypto wallets
  2. Remove persistence artifacts (the script helps with this)
  3. Audit cloud services for unauthorized access (AWS CloudTrail, GCP Audit Logs, Azure Activity Log)
  4. Check Kubernetes for node-setup-* pods and audit secret access
  5. Rebuild from a clean environment rather than upgrading in place

Requirements

  • bash 4+
  • Standard Unix tools (grep, find, lsof)
  • Optional: pyenv, conda, kubectl, brew (scans are skipped if not present)

License

MIT. Use it, share it, fork it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment