pipebreach logo pipebreach.com
General Analysis

Tags Are Pointers. Pointers Move.

April 16, 2026 · 14 min read

This post builds on Part I and Part II. Those explain what happened. This one focuses on the underlying mechanism that made it possible, including a hands-on demo that replicates the attack in a controlled environment.

← Part I: Twenty Days of Silent Access · Part II: Backdooring the AI Credentials Vault →

The implicit assumption

Most pipeline configurations look something like this:

# .github/workflows/scan.yml
- uses: aquasecurity/trivy-action@v0.30.0
FROM python:3.12-slim
# requirements.txt
litellm==1.82.6

The implicit assumption behind all three is that the reference uniquely and stably identifies the artifact that will execute. The version looks pinned. The artifact looks determined.

That assumption is structurally wrong, and it is the gap that modern supply chain attacks operate in.


Declaration, resolution, execution

Every pipeline traverses three steps between declaring a reference and running code:

Escalating Blast Radius Hierarchy

The step that matters is resolution. The pipeline does not execute what it declares. It executes the result of resolving what it declares. In multiple ecosystems that resolution is mutable, externally controlled, and not validated against the declared source.

Author's Note

Writing the TeamPCP series, I kept returning to one question: why did so many vendor write-ups describe what the attacker changed without naming the mechanism that made it possible? The mitigation advice that followed was a list of tools, with no model for what each one actually controls. This post is an attempt to build that model from the bottom up. The demo at the end replicates the tag poisoning mechanic in a controlled environment, not to reproduce the attack, but to make the mechanism visible in a way that a diagram cannot.


GitHub Actions: mutable tag resolution

When a workflow uses aquasecurity/trivy-action@v0.30.0, the Actions runner performs a Git reference lookup:

  1. resolve v0.30.0 against refs/tags/v0.30.0
  2. obtain the commit SHA the tag points to
  3. download the repository tree at that commit
  4. execute action.yml from that tree

The key word is points to. In Git, a tag is a named pointer. There are two types: lightweight tags, which are direct pointers to commits with no additional metadata, and annotated tags, which are standalone objects with their own SHA, creation timestamp, author, and optional GPG signature, themselves pointing to a commit. From a security standpoint both share the same critical property: the pointer can be moved.

git tag -f v0.30.0 <different-commit-sha>
git push --force origin refs/tags/v0.30.0

The tag name stays the same. The commit it resolves to is now different. Every pipeline referencing trivy-action@v0.30.0 will execute the new commit on its next run, with no change to the workflow file itself.

This is what TeamPCP executed on March 19 across 82 tags in 17 minutes, using the maintainer credentials they had been holding since the Pwn Request on February 27.

The orphan commit mechanic

One detail that most post-mortems glossed over is where the malicious commit lived in the repository after the attack.

TeamPCP did not push the malicious code to main or any existing branch. They created what Git calls an orphan commit: a commit with no parent, disconnected from the repository’s main history graph. The workflow was:

# Create a branch with no history
git checkout --orphan malicious-branch
git rm -rf .

# Place the modified entrypoint.sh
# Commit with a spoofed maintainer identity and a backdated timestamp
GIT_AUTHOR_NAME="aqua-bot" \
GIT_AUTHOR_DATE="2023-11-20T09:15:00Z" \
git commit -m "fix: improve scanner performance and reliability"

# Move every tag to the malicious commit
git tag -f v0.30.0 $(git rev-parse HEAD)
git push origin v0.30.0 --force

# Delete the branch. The commit becomes unreachable from any branch.
git push origin --delete malicious-branch

After this sequence, the repository’s main branch was untouched. Anyone reviewing the source code would see the legitimate entrypoint.sh. The malicious commit existed in the object store but was only reachable by following a tag. Every tag had been moved to point at it.

This is the detection signal that tools like StepSecurity Harden-Runner caught: a tag referencing a commit that belongs to no branch.

# This should return at least one branch for any legitimate tag
git branch -a --contains v0.30.0
# Empty output = orphan commit. Not normal.

# Show commits reachable from the tag but not from main
git log main..v0.30.0 --oneline
# If this returns anything, the tag points outside the known history

Version pinning vs. hash pinning

Using a version tag (@v0.30.0) instead of a branch (@main) is version pinning. It reduces accidental drift: the reference only changes when the tag is explicitly moved. But under an active compromise it provides limited protection, because an attacker with push access can move the tag.

# Version pinned, still mutable:
- uses: aquasecurity/trivy-action@v0.30.0

# Hash pinned, immutable:
- uses: aquasecurity/trivy-action@7c2f6d97fdcddea5f1f4373b6d76bc3a0f3c6d89 # v0.30.0

The commit SHA in the second form is a content address. It is derived from the SHA-1 hash of the commit object itself, which includes: the hash of the file tree, the SHA of the parent commit(s), the author, timestamp, and commit message. Any modification to any of these inputs produces a different SHA. It is not a policy constraint enforced by GitHub. It is a mathematical property of the hash function: you cannot change what a SHA resolves to without producing a different SHA.

SHA-1(commit) = SHA-1(
  tree_hash    +   # hash of the entire file tree at this commit
  parent_sha   +   # SHA of the previous commit
  author       +
  timestamp    +
  message
)

This is what makes commit-SHA pinning structurally different from tag pinning. A tag is a mutable label. A SHA is an immutable identity.

Resolving and auditing

To pin an action to a SHA, resolve the tag first:

# Returns the commit SHA for a given tag
gh api repos/aquasecurity/trivy-action/git/ref/tags/v0.30.0 \
  --jq '.object.sha'
# 7c2f6d97fdcddea5f1f4373b6d76bc3a0f3c6d89

Then update the workflow:

# Before
- uses: aquasecurity/trivy-action@v0.30.0

# After: the comment preserves readability without sacrificing immutability
- uses: aquasecurity/trivy-action@7c2f6d97fdcddea5f1f4373b6d76bc3a0f3c6d89 # v0.30.0

To find every unpinned action reference across an organization:

# Lists all action references not pinned to a full 40-character SHA
gh repo list YOUR_ORG --json name --jq '.[].name' | while read repo; do
  gh api "repos/YOUR_ORG/${repo}/contents/.github/workflows" 2>/dev/null \
    --jq '.[].name' | while read wf; do
    gh api "repos/YOUR_ORG/${repo}/contents/.github/workflows/${wf}" \
      --jq '.content' | base64 -d 2>/dev/null \
      | grep -n "uses:" \
      | grep -Ev "@[a-f0-9]{40}" \
      | sed "s|^|${repo}/${wf}: |"
  done
done

This script walks every repository in the org, decodes each workflow file, and extracts uses: lines that don’t match the pattern @ followed by exactly 40 hex characters. The output is a prioritized list of mutable dependencies. Each one is a tag reference that an attacker with push access to the upstream repository can retarget.


Docker: tag mutability and manifest digests

Docker image references follow the same model: a mutable named pointer to a content-addressed object.

docker pull aquasec/trivy:0.69.4

The registry resolves this by querying the manifest API:

GET /v2/aquasec/trivy/manifests/0.69.4
→ returns a manifest listing all layer digests (sha256:...)

The tag 0.69.4 in the registry is a named pointer to that manifest. Anyone with push access can overwrite it:

docker build -t aquasec/trivy:0.69.4 -f Dockerfile.malicious .
docker push aquasec/trivy:0.69.4
# The tag now resolves to the new manifest. Any pull gets the new image.

The equivalent of commit-SHA pinning in Docker is pinning to the manifest digest:

# Resolve the digest for a known-good tag
docker inspect --format='{{index .RepoDigests 0}}' aquasec/trivy:0.69.4
# aquasec/trivy@sha256:3a4f2a8e9c1b7d6e5f...
# Digest-pinned: resolves to the same manifest regardless of tag changes
FROM aquasec/trivy@sha256:3a4f2a8e9c1b7d6e5f...

Docker manifest digests are SHA-256 hashes of the manifest content. Same property as Git SHAs: you cannot change what a digest resolves to without producing a different digest.


PyPI: internal consistency vs. source correspondence

The Python ecosystem introduces a different variant of the same problem. When pip install litellm==1.82.7 runs:

  1. query PyPI Simple API for all distributions of litellm
  2. select the file matching 1.82.7 and the current platform
  3. download the wheel (.whl)
  4. verify the RECORD file: each entry contains the filename and its expected SHA-256 hash; pip checks that the downloaded files match those hashes
  5. install

Step 4 is what pip calls hash verification. The RECORD file inside a wheel looks like this:

# litellm-1.82.6.dist-info/RECORD
litellm/__init__.py,sha256=a3f1b2c4...,12840
litellm/main.py,sha256=d7e8f9a1...,45320
...

This is an internal consistency check. It confirms that the archive you downloaded is intact and matches what was originally packaged. What it does not check is whether the packaged content corresponds to any particular source commit on GitHub.

In the LiteLLM compromise documented in Part II, the injection happened post-build: the wheel artifact was modified after the CI build produced it, and before it was uploaded to PyPI. The RECORD hashes were regenerated to match the modified files. pip’s verification passed, because the archive was internally consistent. The source repository was never touched.

Hash-pinning requirements

The direct mitigation is declaring expected hashes in requirements.txt:

# Generate a hash-pinned requirements file from your current environment
pip-compile requirements.in --generate-hashes -o requirements.txt

The output format includes both the version constraint and the expected wheel hash:

# requirements.txt
litellm==1.82.6 \
    --hash=sha256:a3c8e2f1b4d97... \
    --hash=sha256:f9b7a2c4e1d83...  # sdist hash

Installing with --require-hashes then enforces that every package matches a declared hash, with no exceptions for unlisted packages:

pip install --require-hashes -r requirements.txt

If an attacker publishes a modified wheel under a version you already have pinned, the hash will not match and installation fails. The protection window is: from the moment you captured the hash of a clean version, not before.

The gap hash pinning leaves open

Hash pinning answers: “did I get the same artifact I got last time?” It does not answer: “does this artifact correspond to the source commit it claims to represent?”

Those are different questions, and the second one is what SLSA provenance addresses.


SLSA provenance: closing the origin gap

SLSA (Supply chain Levels for Software Artifacts) is a framework for expressing and verifying the provenance of build artifacts. At Build Level 3, a published package includes a signed attestation that records: the exact source commit the build ran from, the workflow that produced it, the runner environment, and the digest of the resulting artifact.

The signing mechanism is Sigstore with keyless signing via OIDC. During a GitHub Actions build, the workflow receives a short-lived OIDC token from GitHub’s identity provider. That token is used to obtain a signing certificate from Fulcio (Sigstore’s CA), which binds the signing identity to the specific workflow execution. The signature and certificate are logged to Rekor, Sigstore’s transparency log, creating a permanent, publicly auditable record.

The resulting provenance bundle ties together:

artifact digest  ←→  source commit  ←→  build workflow  ←→  Sigstore signature

Forging this chain requires compromising GitHub’s OIDC endpoint or Sigstore’s infrastructure, not just obtaining a publish token.

For packages that publish SLSA provenance (PyPI supports this as of PEP 740), verification before deployment:

# pip 24.1+ supports attestation verification natively
pip install --require-hashes --verify-attestations -r requirements.txt

For manual verification with slsa-verifier:

pip download "litellm==1.82.6" --no-deps -d /tmp/verify/

slsa-verifier verify-artifact /tmp/verify/litellm-1.82.6-py3-none-any.whl \
  --provenance-path /tmp/verify/litellm-1.82.6.provenance \
  --source-uri github.com/BerryAI/litellm \
  --source-tag v1.82.6

A post-build injection like the one in LiteLLM 1.82.7 produces a hash mismatch: the attestation was signed against the pre-injection artifact during the CI build. The published wheel hash diverges from the attested hash. The verification fails with a specific error rather than silently installing.


What each control actually protects

ControlWhat it fixesWhat it leaves open
Version pinning (@v1.2.3, pkg==1.2.3)Accidental drift from floating referencesTag or version can still be retargeted by attacker
Commit SHA / manifest digest pinningMutable resolution; tag force-pushDoesn’t validate the origin of the pinned content
--require-hashes (pip)Silent package substitution after pinningPinned hash might have been captured post-compromise
SLSA provenance verificationPost-build artifact injectionOnly covers packages that publish attestations

There is no single control that closes all of these. The correct model is layered: hash pinning eliminates mutable resolution, provenance verification validates origin, and both are necessary for a complete defense.


The residual gap

After applying hash pinning and provenance verification, a structural gap remains. Supply chain attacks can target the build environment itself rather than the published artifact. An attacker operating inside a CI runner (as TeamPCP did via credential scraping from compromised pipelines) is positioned before provenance is generated. If source files are modified during the build and restored after, the resulting artifact is signed by the legitimate process against a commit that never showed the malicious code.

Closing this requires hermetic builds (the build environment has no network access and no write access to the source tree after checkout) and reproducible builds (two independent builds from the same source produce bit-for-bit identical artifacts). Neither is standard practice. Most of the industry is still working on getting hash pinning right.


Immediate Hardening One action per ecosystem

GitHub Actions. Audit unpinned references, resolve each to its commit SHA, and update workflows.

# Find every non-SHA-pinned action reference in your org
grep -r "uses:" .github/workflows/ \
  | grep -Ev "@[a-f0-9]{40}" \
  | awk -F'uses:' '{print $2}' | sort -u

# Resolve a tag to its commit SHA
gh api repos/{OWNER}/{REPO}/git/ref/tags/{TAG} --jq '.object.sha'
# Then: uses: owner/action@<sha> # original-tag

Docker. Resolve and pin manifest digests.

docker inspect --format='{{index .RepoDigests 0}}' image:tag
# Use the output as: FROM image@sha256:...

Python. Generate hash-pinned requirements at a known-good version and enforce them in CI.

pip-compile requirements.in --generate-hashes -o requirements.txt
pip install --require-hashes -r requirements.txt

This post is part of an ongoing series on supply chain attack mechanics. ← Start from Part I

Demo

The mechanics described in this post (orphan commits, tag force-push, identity spoofing, silent payload execution) are replicated step by step in a controlled demo environment using two public repositories under the pipebreach org: one acting as the compromised action, one acting as the victim pipeline.

The demo is not a deep pentest. It is a practical walkthrough of exactly what happened with trivy-action, simplified enough to follow in real time, with a live exfiltration to a webhook endpoint showing secrets leaving the runner while the pipeline reports a clean scan.


References

  1. Wiz Research: Trivy Compromised by TeamPCP (Mar 2026)
  2. Wiz Research: TeamPCP Trojanizes LiteLLM (Mar 2026)
  3. SLSA Framework: Supply Chain Levels for Software Artifacts
  4. Sigstore: Keyless signing with OIDC
  5. PEP 740: Index support for digital attestations
  6. William Woodruff: We should all be using dependency cooldowns (Nov 2025)
  7. GitHub Security Lab: Preventing Pwn Requests (2021)
  8. pipebreach: trivy-action-demo (demo repository)
DM
Daniel Malvaceda

DevSecOps Engineer

Occasionally breaks AI pipelines and supply chains to see what falls out..