Rotating the Keys Is Step One: The Forensic Investigation Behind a Python Package Compromise
Source: simonwillison
When Simon Willison published his minute-by-minute account of responding to the LiteLLM malware attack, the most instructive part was the process, not the conclusion. Most incident write-ups are post-mortems shaped by knowing the outcome. The dead ends get edited out. The moment where the responder had three hypotheses and ran them in parallel becomes a clean sentence: “we investigated and found.” Willison’s format preserved the uncertainty, and that makes it genuinely more useful than the polished version would have been.
The format also forces a reckoning with a question most developers never think about until they are already inside an incident: after you rotate your API keys, then what? Key rotation is the right first step for any suspected compromise of LLM proxy infrastructure. It is also just the beginning. The question that follows is harder and slower to answer: what did the compromised version do, and for how long was it doing it?
That question requires forensic investigation. For Python packages, the tooling is less obvious than in other ecosystems, and LiteLLM’s specific architecture changes the scope of what you are looking for.
Install-Time Malware Versus Runtime Malware
The first diagnostic question with any compromised package is whether the malicious code operates at install time or at runtime. The distinction changes the blast radius and the urgency of the investigation in ways that matter.
Install-time malware executes during pip install, inside setup.py, wheel hooks, or package metadata lifecycle scripts. It runs once, while the package is being unpacked into a site-packages directory, with access to whatever environment variables are present in the shell at that moment. The canonical attack is exfiltrating $AWS_SECRET_ACCESS_KEY or $HOME/.ssh/id_rsa from a developer’s machine or CI runner. These attacks have a bounded exposure window: what was in the environment during the specific install invocation.
Runtime malware activates when the package is imported or when specific functions are called. For a library like LiteLLM, which runs as a persistent proxy server processing every LLM request your application makes, the exposure profile is entirely different. It is not a single event with a timestamp you can look up. It is a continuous exposure for the full lifetime of the deployment, against every request processed during that window.
Determining which category you are dealing with should happen in parallel with key rotation, not after it. Install-time malware investigation involves checking what was in your environment at install time. Runtime malware investigation requires auditing every request that transited the proxy during the affected period.
LiteLLM’s Callback System as the Obvious Injection Point
LiteLLM exposes a callback system designed for logging and observability. You can register callbacks that receive events on every LLM request, response, and error:
import litellm
def my_callback(kwargs, completion_response, start_time, end_time):
# Receives the full request: API key, model, messages, and response
pass
litellm.callbacks = [my_callback]
The system exists for legitimate integration with platforms like LangFuse or Helicone. But it also means that any code executed during initialization can register a callback that receives every API key, every system prompt, and every message that flows through the proxy. A malicious version would not need to inject obfuscated network calls into the core request path. It could register a callback during package initialization, a few lines buried in an __init__.py or a utility module, that silently forwards everything it receives.
This is the architecture-specific risk. Generic Python package malware needs to go hunting for credentials in environment variables or config files. A compromised LiteLLM does not; the credentials and the content are delivered to it with every call. The tool was designed to touch every request, so exploiting that design requires very little additional code and produces very little structural noise.
When investigating a potentially compromised LiteLLM installation, the callback registration surface is the first place to look. The litellm/litellm_core_utils directory, the provider integration modules, and any custom callback integrations bundled with the release are worth reading carefully.
The Forensic Toolchain for Python Packages
Once you have rotated your keys, the investigation focuses on what the installed package contained and what it was doing.
pip show --files litellm lists every file installed by the package. The list for LiteLLM is long: the proxy server, the provider integrations, the utils directory, and a substantial number of callback integrations. The first pass is looking for files with unusual names, unexpected files compared to what the package structure should contain, or files with modification timestamps that do not match the install date.
pip-audit checks installed packages against the OSV vulnerability database and PyPI security advisories. For a package that was briefly compromised and then yanked, the advisory may not exist yet at the time you run the audit, but the check is worth doing.
For a deeper comparison, diffoscope is useful if you have the .whl file from the compromised release, which you might, since many teams cache downloaded packages in CI artifact stores or local pip cache directories. Comparing the compromised wheel against a known-good release byte-by-byte reveals exactly what changed. A single modified file with a few extra lines in an initialization path is the pattern to look for.
Static analysis using Python’s ast module can scan installed source files for suspicious patterns across the full package tree:
import ast, os
for root, dirs, files in os.walk(litellm_install_path):
for fname in files:
if not fname.endswith('.py'):
continue
fpath = os.path.join(root, fname)
with open(fpath) as fh:
try:
tree = ast.parse(fh.read())
except SyntaxError:
continue
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name in ('socket', 'requests', 'urllib', 'http.client'):
print(f"{fpath}: imports {alias.name}")
This is a heuristic, not a complete scanner. It will produce false positives in legitimate provider integration code that makes network calls. The value is in narrowing the search space: flagging files that import networking modules but should not, given what that file’s documented role is.
Auditing for Exploitation That Already Happened
Key rotation stops future abuse. It does not address what was already observed. Auditing for past exploitation requires two parallel tracks: provider-level usage logs and network logs.
Every major LLM provider exposes usage data with enough granularity to detect anomalies. OpenAI’s usage dashboard shows requests by model, time, and approximate token count. Anthropic’s console breaks down usage per API key. Azure OpenAI logs requests through Azure Monitor, queryable via AzureDiagnostics in Log Analytics. What you are looking for in each: requests to models your application does not use, requests at times when your system should be idle, token counts that do not match your typical workload baseline, and spikes in request volume that do not correspond to your own traffic patterns.
Network logs are more definitive but less uniformly available. A runtime exfiltration callback makes outbound connections to an attacker-controlled server. In environments with egress filtering or comprehensive cloud flow logging, those connections appear as anomalous destinations. In environments without egress controls, which describes a large fraction of internal LiteLLM deployments running behind a VPC with permissive outbound rules, the network audit is limited to whatever your cloud provider captures by default.
For teams running LiteLLM with LiteLLM’s own request logging callbacks writing to a backend, those logs are the most direct source. If you were already logging proxy requests to a database or a platform like Helicone, the anomaly detection problem becomes concrete: look for requests you did not send.
The Data Exposure Beyond the API Keys
API keys can be rotated and invalidated. The data that transited the proxy during the exposure window cannot be un-exfiltrated, and this part of the blast radius is easy to understate in the urgency of containment.
For teams using LiteLLM in production, the exposed data potentially includes the full content of every user message sent to any provider, the system prompts that define how products behave, the completion content returned by models, and in cases involving fine-tuned models, the identifiers of those models and the inference patterns they produce.
System prompts are often treated as proprietary business logic. They encode product positioning, operational constraints, and sometimes access to internal knowledge sources. For teams that have invested significantly in prompt engineering, the exposure of system prompts is separate from and in some cases more consequential than the credential exposure, because rotated credentials stop working immediately while observed system prompts are knowledge in an attacker’s hands.
A complete incident response should include this data exposure in the assessment, even if quantifying it is difficult. Affected teams running customer-facing products may have notification obligations depending on what data flowed through the proxy and whether it constitutes personal data under GDPR, CCPA, or applicable jurisdiction.
What the Minute-by-Minute Format Preserves
The forensic steps above are the technical substance of what a careful incident responder works through in real time. Willison’s write-up is valuable precisely because it shows a practitioner navigating these steps without the benefit of knowing in advance which hypotheses would pan out.
Security training materials and runbooks describe what to do in the abstract. Seeing an experienced engineer actually sequence through the investigation, including the false starts and the moments of waiting on log queries to return, is a different kind of instruction. The format is uncomfortable to produce because it exposes uncertainty. That discomfort is the reason the artifact is useful. Teams building their own incident response capabilities will learn more from a messy real-time account than from a polished retrospective written once everything was already understood.