|
#!/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}" |