By falco365 · Published April 30, 2026

How a GitHub Actions comment box became a supply-chain attack: elementary-data on PyPI and GHCR

An attacker injected commands into elementary-data's CI pipeline through a GitHub Actions script-injection flaw, forged a release tag, and delivered a malicious .pth file to both PyPI and GitHub Container Registry.

How a GitHub Actions comment box became a supply-chain attack: elementary-data on PyPI and GHCR
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.3 on PyPI and ghcr.io/elementary-data/elementary:0.23.3. The :latest GHCR 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 the GITHUB_TOKEN in 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.pth file in site-packages. Python's import system evaluates .pth directives at interpreter startup, unconditionally. Every python invocation 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_target or issue_comment that also check out PR code or use secrets should be treated as high-risk and reviewed for injection paths.
  • Audit GITHUB_TOKEN scope. 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:latest during the incident window

Git artifacts:

  • Tag v0.23.3 pointing to orphan commit b1e4b1f3aad0d489ab0e9208031c67402bbb8480
  • Compromised image digest: sha256:31ecc5939de6d24cf60c50d4ca26cf7a8c322db82a8ce4bd122ebd89cf634255

Filesystem artifacts:

  • elementary.pth in any Python site-packages directory
  • trin.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/null on 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:31ecc5939de6d24cf60c50d4ca26cf7a8c322db82a8ce4bd122ebd89cf634255 should 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 .env files.
  • 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.body inside run: blocks. These are the most common injection surfaces. Use zizmor or StepSecurity's harden-runner for 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.