Supply chain attacks tend to expand quickly once an attacker gains a foothold. In this case, a compromised token and CI misconfiguration in Trivy led to a broader campaign spanning multiple ecosystems and distribution channels.
What started with Trivy has now evolved into a multi-stage attack chain involving GitHub Actions, Docker images, npm packages, and Python libraries. The attacker reuses stolen credentials at each step to move laterally and increase reach.
We’ll break down how the attack evolved and what this means for Supply Chain defense and Semgrep rules.
Semgrep Customers
Early Tuesday, 24 March 2026 we published a rule for LiteLLM for all Semgrep Supply Chain customers. We are actively investigating our customers and we do not believe any Semgrep customers are affected by the LiteLLM attack.
Search the advisories dashboard for LiteLLM to verify:
For more Indicators of Compromise (IoCs), and remediation advice for non-customers we recommend the open GitHub issue that is being continually updated by responders and the open source community. However, PyPI quickly quarantined the malicious packages and these versions were fully removed from the repository.
However, we recommend that everyone who has used Trivy in their CI/CD workflows, including customers, users and open source projects immediately rotate credentials listed below in the “Breaking Down This Malware” as we expect further compromises from this attack may come to light in the next few days and weeks. And this is unlikely to be the last attack from these threat actors.
For Semgrep customers who are concerned about further attacks, we are actively monitoring this situation and will release rules if other packages are affected. We are also actively monitoring our own GitHub Actions and our open source ecosystem to ensure Semgrep is not affected.
What is LiteLLM?
LiteLLM is an open-source Python library that provides a unified interface for calling many different LLM APIs using the same OpenAI-compatible syntax. So instead of learning each provider's SDK separately, you write your code once and LiteLLM handles the translation. Allowing you to easily offer a choice of LLM model in an AI application without having to repeat code in slightly different API syntaxes.
The proxy is probably its most popular feature in enterprise contexts. Teams can deploy LiteLLM as an AI gateway, set up routing rules, budgets, and auth, and their existing OpenAI-based apps work unchanged while they get provider flexibility underneath as LLM capabilities change and models are updated.
The Trivy Breach
Trivy is an open-source security scanner made by Aqua Security. It's a widely used vulnerability scanner, and many open-source projects use Trivy to help find vulnerabilities in CI/CD pipelines before builds are published for the public. In late Feb/early March, an automated bot called hackerbot-claw exploited a pull_request_target workflow misconfiguration to steal a Personal Access Token from the Aqua Security GitHub org.
Aqua rotated credentials, but the attacker managed to retain their access with a single bot account (Argon-DevOps-Mgt) with write/admin access across both the public and internal GitHub orgs.
With this access they conducted 3 separate attacks:
Trivy binary (v0.69.4): Using the stolen credentials, attackers pushed a malicious Trivy release that ran a credential stealer alongside the legitimate scanner. It exfiltrated env vars, SSH keys, cloud credentials, Kubernetes tokens, and crypto wallets to a typosquatted domain (scan.aquasecurtiy[.]org). If exfiltration failed, it staged stolen data in a public GitHub repo named tpcp-docs using the victim's own PAT.
GitHub Actions poisoning: The attacker force-pushed 75 of 76 version tags in trivy-action and 7 tags in setup-trivy to malicious commits — meaning anyone referencing those actions by tag (which is most people) pulled the infostealer into their CI pipeline.
Docker Hub: Malicious image tags 0.69.5 and 0.69.6 were pushed to Docker Hub with no corresponding GitHub releases.
Another Worm Hits NPM
From these three attacks the attackers had quite the stash of stolen tokens, for cloud providers, crypto wallets, but also npm tokens. This is where the attackers then pivoted once again. The stolen npm tokens from Trivy were used to seed CanisterWorm into 50+ npm packages. CanisterWorm takes inspiration from the NPM attacks we saw in 2025, with a 4 stage attack.
Stage 1: postinstall hook: The malicious package.json uses npm's standard postinstall field to automatically run index.js on every install, no user action required. It immediately hunts for npm tokens in .npmrc files, environment variables, and live npm config.
Stage 2: persistent backdoor: A base64-encoded Python script is decoded and written to ~/.local/share/pgmon/service.py, then installed as a systemd user service named pgmon — designed to look like a PostgreSQL monitoring tool. Crucially, this requires no root access.
Stage 3: ICP C2: Rather than a conventional web server, the backdoor polls an ICP (Internet Computer Protocol) canister — decentralised blockchain infrastructure with no single host, meaning it cannot be taken down via a conventional takedown request. The attacker can rotate payloads at any time without touching the npm packages. At time of discovery the canister was returning a YouTube URL, meaning the final payload was dormant but the infrastructure was fully live.
Stage 4: self-propagation: A detached deploy.js process authenticates with each stolen npm token, enumerates every package that token has publish access to, bumps the patch version, and republishes the entire worm package under the victim's package name with --tag latest. The victim spreads it without knowing.
Deliberate Attempt to Embarrass Security Vendors
Whilst this was all ongoing in the NPM ecosystem the attackers were planning their next moves. True to their MO so far: TeamPCP used credentials stolen during the Trivy compromise to poison two Checkmarx GitHub Actions:
Sharing an update on their telegram channel, they brag about the breach of well known security vendors: “These companies were built to protect your supply chains yet they can't even protect their own, the state of modern security research is a joke..”, ending with: “Thanks checkmarx we love you”.
Wait, Weren’t We Talking About LiteLLM?
It’s already been quite the week for supply chain security, but TeamPCP isn't done yet. LiteLLM, like many other popular open source projects, also leverage CI/CD pipelines, running a shell script called security_scans.sh, they search for secrets, CVEs and vulnerabilities, using Trivy. This is likely how the package was first affected, pulling in the malware during the first compromise.
Rather than a postinstall hook (which people are increasingly aware of), the malware in LiteLLM uses a .pth file, litellm_init.pth dropped into Python's site-packages. Python auto-executes these on every interpreter startup. You don't even need to import litellm simply running python --version triggers the .pth file.
Again true to form, the malware’s payload is another credential harvesting script. Finding SSH keys, AWS (including full IMDSv2 implementation and Secrets Manager enumeration), GCP, Azure, Kubernetes, .env files, shell history, git credentials, Docker registries, Terraform state files, TLS private keys, Solana validator keypairs, and more. If it runs inside a K8s pod with a permissive service account, it creates privileged DaemonSets across every node including control plane, mounts the host filesystem, and installs a backdoor directly onto the underlying host. Just like before once it collects credentials it exfiltrates them to models[.]litellm[.]cloud another domain mimicking the legitimate project.
Breaking Down This Malware
There are 3 stages to this version of the credential stealer: an orchestrator, a harvester and a backdoor. The orchestrator starts the infostealer and enables the exfiltration, encrypting the credential file, and POSTs it to the attacker’s C2. The harvester collects a truly mindblowing amount of credentials, with many ecosystem-related credentials that could be used to continue these attacks. And finally the backdoor allows the malware to persist beyond the initial compromise.
The samples for security researchers interested in understanding this breach were captured by HackingLZ in this GitHub repository.
Stage 1: Orchestrator
The orchestrator is relatively simple, starting the infostealer, collecting the results, and sending the file back to the attacker.
Base64-decodes the Stage 2 collector (stored as B64_SCRIPT) and runs it via subprocess.run([python, "-"]), feeding the decoded script on stdin and capturing all stdout to a temp file. All the credentials Stage 2 collects are written to stdout, so they land in this temp file.
Generates a random 256-bit AES session key using openssl rand.
Encrypts the collected credential file with AES-256-CBC (PBKDF2 key derivation) using that session key.
Encrypts the AES session key with the attacker's RSA-4096 public key (OAEP padding); this key is hardcoded in the file.
Bundles both encrypted files (payload.enc + session.key.enc) into a .tar.gz.
POSTs the bundle to hXXps://models[.]litellm[.]cloud/ via curl.
The interesting part here is the hybrid encryption used on the final payload: The exfiltrated bundle is unreadable to anyone except the attacker, even if you intercept the network traffic or find a cached copy of the .tar.gz, you cannot recover the credentials without the attacker's private key. Making it extremely difficult to understand exactly what credentials have been stolen, and what may need to be rotated.
Stage 2: Harvester
This is the largest and most comprehensive component. It runs as a subprocess with its stdout piped back to Stage 1, so everything it prints becomes part of the exfiltrated bundle. It writes data in a structured === /path/to/file === format. This one of the most comprehensive infostealers we’ve seen in supply chain security, easily beating out the stealers from the npm breaches back in 2025.
Category | What's Collected |
|---|
SSH | id_rsa, id_ed25519, id_ecdsa, id_dsa, full ~/.ssh/ recursive walk, server host keys from /etc/ssh/, authorized_keys, known_hosts
|
AWS | ~/.aws/credentials, ~/.aws/config, env vars (`AWS_ACCESS_KEY_ID` etc.), ECS link-local endpoint, EC2 IMDSv2 token flow, IAM role credentials, Secrets Manager values, SSM Parameter Store
|
GCP | `~/.config/gcloud/` (all files incl. access_tokens.db, credentials.db), application_default_credentials.json, $GOOGLE_APPLICATION_CREDENTIALS, env vars |
Azure | ~/.azure/ directory, env vars
|
Kubernetes | ~/.kube/config, admin.conf, kubelet.conf, mounted service account tokens (/var/run/secrets/kubernetes.io/serviceaccount/token), /var/secrets, /run/secrets, live K8s API secret dump
|
Environment Config | .env, .env.local, .env.production, .env.development, .env.staging, .env.test — current dir, parent dirs, and recursive search across /home, /root, /opt, /srv, /var/www, /app, /data etc.
|
Shell History | .bash_history, .zsh_history, .sh_history, .mysql_history, .psql_history, .rediscli_history
|
Git | ~/.git-credentials, ~/.gitconfig
|
Docker | ~/.docker/config.json, /kaniko/.docker/config.json
|
Databases | ~/.my.cnf, ~/.pgpass, ~/.mongorc.js, /etc/mysql/my.cnf, /etc/redis/redis.conf, Postfix SASL, LDAP configs, env vars matching DATABASE, DB_, MYSQL, POSTGRES, MONGO, REDIS, VAULT
|
IaC and CI/CD | *.tfvars, terraform.tfstate, .gitlab-ci.yml, .travis.yml, Jenkinsfile, .drone.yml, ansible.cfg, Helm chart directories
|
TLS / SSL Private Keys | /etc/ssl/private/*.key, /etc/letsencrypt/**/*.pem, recursive search for .pem, .key, .p12, .pfx
|
Solana | ~/.config/solana/, validator keypairs (validator-keypair.json, vote-account-keypair.json etc.), Anchor deployment keys, recursive search for keypair.json, wallet*.json across typical validator paths
|
Crypto Wallets | Bitcoin wallet*.dat, Ethereum keystore, Cardano, Litecoin, Dogecoin, Zcash, Dash, Ripple, Monero |
System | /etc/passwd, /etc/shadow, recent SSH auth logs
|
VPN | WireGuard configs (/etc/wireguard/*.conf), wg showconf all |
Other Credentials | HashiCorp Vault (~/.vault-token), npm (~/.npmrc), .netrc, .msmtprc (SMTP), Slack/Discord webhook URLs |
Stage 2b: Kubernetes lateral movement
If you’re using Kubernetes, the attack continues. If a Kubernetes service account token is present (i.e., if the malware is running inside a K8s pod), it escalates from credential theft to full cluster compromise.
Calls the K8s API to list all nodes.
For each node, creates a privileged pod in the kube-system namespace with:
privileged: true security context
hostPID: true and hostNetwork: true
The host root filesystem mounted at /host
Tolerations that allow scheduling on any node, including control plane nodes
The pod runs a shell command that uses chroot /host to write Stage 3 (sysmon.py) directly onto the underlying host's filesystem and installs it as a systemd service.
A single compromised container with a permissive service account role becomes full compromise of every node in the cluster.
Stage 3: Backdoor
This is the persistent remote access component. It is dropped onto the victim machine by Stage 2 and runs indefinitely as a systemd service, giving the attacker ongoing arbitrary code execution long after the initial credential theft is complete.
Stage 2 handles the installation via two paths:
Decodes Stage 3 from its own embedded base64 blob (PERSIST_B64)
Writes it to ~/.config/sysmon/sysmon.py with permissions 0700
Creates a systemd user unit at ~/.config/systemd/user/sysmon.service
- The unit is configured with Restart=always and RestartSec=10, suppresses all stdout/stderr output, and is described as System Telemetry Service, a name chosen to look like a legitimate monitoring agent
Enables and starts it immediately: systemctl --user enable --now sysmon.service
And for the Kubernetes compromise:
The same dropper is delivered via privileged pod using chroot /host into the underlying node OS
Installs at the system level (not user level) so it survives container restarts
On startup, sleeps for 300 seconds (5 minutes). This delay is designed to avoid triggering behaviour-based detection tools that watch for immediate network activity after a new process starts.
Enters an infinite loop** that repeats every 3000 seconds (~50 minutes):
Fetches hXXps://checkmarx[.]zone/raw with a generic browser User-Agent header
Reads the response as a plain string, the C2 server returns a URL
Reads /tmp/.pg_state to get the URL that was last executed
If the new URL: is not empty, is different from the previously executed URL (no re-execution of same payload) and does not contain youtube.com (YouTube URLs are the attacker's dormancy signal, returning a YouTube link means "no new payload right now")
…then it downloads the binary at that URL to /tmp/pglog, sets it executable (`chmod 755`), and launches it with subprocess.Popen(..., start_new_session=True and fully detached from the backdoor's own process
Records the executed URL to /tmp/.pg_state
The C2 operator controls what URL checkmarx.zone/raw returns at any given time. When no active campaign is running, returning a YouTube URL causes every infected host to silently do nothing. This keeps the beaconing traffic appearing benign (just a GET request with no suspicious follow-on activity) and avoids burning the C2 infrastructure when it isn't needed.
But in the meantime the attacker has full arbitrary code execution, as the C2 endpoint can serve a new binary or script at any time (including updates to the initial payload). Creating a persistent, silent infection that can easily lie dormant for many days before being noticed.
Conclusion
This particular group TeamPCP has been extremely active in the past few weeks, and it looks like security vendors and high profile projects are in the firing line. The attackers first gained access to LiteLLM during their attack last week on Trivy. But despite the scale of the breach, they didn't rush to attack while defenders were already on high alert. With cascading compromises all linked to Trivy and unsecured GitHub Actions.
It all traces back to the initial breach from late February in hackerbot-claw that shows incomplete remediation can become a falling set of dominoes. As the attackers sat on their access, waiting until defenders were busy with a major security conference before launching this payload.
The attackers may be sitting on many more compromises across the open source ecosystem, waiting for guards to go down before launching another one.