Analysis
A single unguarded interpolation in a GitHub Actions workflow is the difference between a routine release pipeline and a supply-chain attack that publishes to two registries simultaneously. The compromise of elementary-data==0.23.3, first reported by StepSecurity on April 30, 2026, demonstrates the class precisely: an attacker injected commands into a CI workflow by exploiting a pull-request comment field that was interpolated unsanitized into a shell run: block. The injected code ran inside the workflow context with a GITHUB_TOKEN in scope, which the attacker used to forge a release commit, push a tag, and dispatch the project's own publishing workflow against that tag. The result was a trojaned elementary-data package on PyPI and a multi-arch container image on GitHub Container Registry — both signed artifacts, both from the project's legitimate publishing infrastructure.
What we know
- April 30, 2026 — StepSecurity reports the compromise of
elementary-data==0.23.3on PyPI andghcr.io/elementary-data/elementary:0.23.3. The:latestGHCR tag pointed to the compromised image during the incident window. - Attack vector — GitHub Actions script injection via a workflow that interpolated issue comment content directly into a shell
run:step. The attacker crafted a comment body containing shell metacharacters; the workflow expanded them as commands under the GitHub Actions runner with full access to theGITHUB_TOKENin the workflow environment. - Payload delivery — Using the
GITHUB_TOKEN, the attacker created an orphan commit (b1e4b1f3aad0d489ab0e9208031c67402bbb8480), applied a Git tag (v0.23.3) against it, and dispatched the project's legitimate publishing workflow. That workflow published both the PyPI release and pushed a multi-arch container image to GHCR under the compromised tag. - Payload mechanism — The malicious PyPI release ships an
elementary.pthfile insite-packages. Python's import system evaluates.pthdirectives at interpreter startup, unconditionally. Everypythoninvocation in an environment with the package installed executes the payload — no explicit import required, no call depth to trace.
The attacker didn't break into the repository. They posted a comment. The workflow's trust boundary was set at the wrong place: it treated pull-request comment content as safe input to a shell command, when in fact that content is attacker-controlled by design. The release infrastructure — OIDC-signed, multi-arch, running the project's own workflow — then did exactly what it was built to do.
The .pth injection technique
.pth files in Python's site-packages directory are a legitimate path extension mechanism — they're meant to add directories to sys.path. But lines beginning with import are evaluated as Python statements at interpreter startup (via site.py), not just used for path extension. A malicious .pth file containing import os; os.system(...) or equivalent runs on every python invocation, including subprocesses spawned by CI runners, Docker entrypoints, and package import machinery.
StepSecurity reports the elementary.pth file used a multi-stage decode-and-decrypt chain before executing the credential-collection payload. The staging chain is consistent with the broader TeamPCP toolchain pattern seen in litellm and xinference compromises: the first stage is a short bootstrap that decodes an encrypted second stage, which then runs the credential sweep.
The persistence property is the critical difference from a postinstall hook. A postinstall hook runs once at install time and is done. A .pth injector is re-executed on every Python invocation for as long as the package remains installed. That includes long-running production services, not just the install-time window.
Credential collection and exfiltration
StepSecurity reports the payload's collection scope targets the full developer and CI credential surface:
- SSH keys and git credentials
- GitHub tokens — personal access tokens, OIDC tokens, and GitHub Actions secrets accessible from the workflow context
- Cloud credentials — AWS, Azure, and GCP CLI configuration and credential files
- Kubernetes and Docker configuration files —
~/.kube/config, Docker authentication configs - Environment files —
.env,.env.*, and similar secret-bearing files
Collected data is staged into a trin.tar.gz archive in $TMPDIR/.trinny-security-update before exfiltration to the attacker's collection endpoint.
The GHCR vector
Container image compromise from the same publishing workflow adds a second exposure surface that PyPI-focused detection often misses. Teams that pin Python package versions in their requirements.txt or lockfiles but pull ghcr.io/elementary-data/elementary:latest in a Dockerfile without digest pinning received the malicious image during the incident window. The multi-arch build means the payload runs on both linux/amd64 and linux/arm64 deployments.
Digest-pinning container images is the structural defense — equivalent to lockfile pinning for Python packages, but less commonly enforced in practice. The compromised image digest was sha256:31ecc5939de6d24cf60c50d4ca26cf7a8c322db82a8ce4bd122ebd89cf634255; any workload pulling :latest or :0.23.3 during the incident window received this digest.
GitHub Actions script injection: the root cause class
This compromise belongs to a well-documented GitHub Actions vulnerability class. The OWASP definition: script injection occurs when attacker-controlled workflow context values (issue titles, comment bodies, branch names, PR descriptions) are interpolated directly into shell commands using the ${{ ... }} expression syntax inside a run: step.
Example of the vulnerable pattern:
- name: Check comment
run: |
echo "${{ github.event.comment.body }}"
Any comment body containing "; malicious_command; echo " breaks out of the echo context. The correct mitigations are:
- Use environment variables as the intermediary: set
env: COMMENT_BODY: ${{ github.event.comment.body }}and reference$COMMENT_BODY(the shell variable) inside the script. Shell variable expansion doesn't evaluate the content as code. - Restrict workflow triggers. Workflows triggered by
pull_request_targetorissue_commentthat also check out PR code or use secrets should be treated as high-risk and reviewed for injection paths. - Audit
GITHUB_TOKENscope. Workflows that handle untrusted input should have minimally-scoped tokens — read-only if no write operations are needed. The ability to create tags and dispatch workflows should require explicit permissions.
Indicators of compromise
Affected artifacts:
- PyPI:
elementary-data==0.23.3 - GHCR:
ghcr.io/elementary-data/elementary:0.23.3 - GHCR:
ghcr.io/elementary-data/elementary:latestduring the incident window
Git artifacts:
- Tag
v0.23.3pointing to orphan commitb1e4b1f3aad0d489ab0e9208031c67402bbb8480 - Compromised image digest:
sha256:31ecc5939de6d24cf60c50d4ca26cf7a8c322db82a8ce4bd122ebd89cf634255
Filesystem artifacts:
elementary.pthin any Pythonsite-packagesdirectorytrin.tar.gz$TMPDIR/.trinny-security-update
Network indicator (defanged):
igotnofriendsonlineorirl-imgonnakmslmao[.]skyhanni[.]cloud— exfiltration endpoint
Detection and mitigation
- Search for the .pth file immediately. Run
find $(python3 -c "import site; print(' '.join(site.getsitepackages()))") -name "elementary.pth" 2>/dev/nullon all Python environments. Presence means the payload has been installed and is executing on every Python invocation. - Check all container workloads for the compromised digest. Any workload running image digest
sha256:31ecc5939de6d24cf60c50d4ca26cf7a8c322db82a8ce4bd122ebd89cf634255should be considered compromised and rebuilt from a clean base. - Review network telemetry for outbound DNS or HTTP to
igotnofriendsonlineorirl-imgonnakmslmao[.]skyhanni[.]cloud. - Rotate all credentials reachable from affected environments. GitHub tokens, SSH keys, cloud credentials (AWS/Azure/GCP), Kubernetes service account tokens, package registry publish tokens, and any secrets present in
.envfiles. - Audit CI workflows for script-injection patterns. Grep for
${{ github.event.issue,${{ github.event.comment,${{ github.event.pull_request.title, and${{ github.event.pull_request.bodyinsiderun:blocks. These are the most common injection surfaces. Usezizmoror StepSecurity'sharden-runnerfor automated detection. - Pin container images by digest, not by tag. Tags are mutable; digests are not.
ghcr.io/elementary-data/elementary@sha256:<known-good-digest>cannot be silently overwritten.
Attribution
The compromise vector (GitHub Actions script injection) is distinct from the credential-theft-and-publish pattern seen in the Shai-Hulud and TeamPCP clusters. The payload structure — multi-stage decode, .pth file persistence, trin.tar.gz staging — overlaps with TeamPCP tooling reported by JFrog in the xinference compromise. Whether the GH Actions injection vector represents the same operator expanding their initial-access repertoire, or a different actor reusing shared tooling, is not settleable from public evidence. We track this as a distinct campaign instance and note the payload-level overlap for defenders who may have seen the trin.tar.gz artifact elsewhere in their environments.
Criminal-market signal
Dark-web sweeps run on May 6, 2026 found no criminal-market presence for this campaign on publicly-observable venues.
The payload fingerprints — the .pth file persistence mechanism and multi-stage decode architecture — are explicitly linked to the TeamPCP toolchain in CanisterWorm's own self-attribution string: "Technique: .pth file injection (TeamPCP/LiteLLM method)." The TeamPCP cluster has produced clean dark-web negatives across every sweep of every campaign in the cluster. That pattern holds here. The operator uses the tooling; they do not sell it. There is no criminal market for this capability because the attacker is the capability.
The detection surface is the GitHub Actions workflow — specifically, workflows that interpolate attacker-controlled input into shell commands — not dark-web forums. By the time criminal-market monitoring would surface anything, the CI/CD pipeline has already been compromised and the forged release published.
What this means for defenders
The lesson from this compromise is not about elementary-data specifically — it's about the trust boundary assumption baked into most CI security models. The boundary is typically drawn at "who can push to the repository." But GitHub Actions workflows that respond to external events (comments, issue titles, PR bodies) extend that boundary to anyone who can interact with the repository publicly. That includes unauthenticated users on public repos.
The structural defenses are three layers deep: (1) never interpolate attacker-controlled fields into shell commands — use environment variables instead; (2) scope GITHUB_TOKEN to the minimum permissions the workflow requires; (3) pin artifacts by digest at every boundary — both Python packages in lockfiles and container images in Dockerfiles. The TeamPCP campaign abused tag mutability in GitHub Actions; this compromise abused input interpolation. Different initial-access vector, same structural gap in supply-chain trust assumptions.