
75 Poisoned Tags and Nobody Noticed
Somebody rewrote trivy-action. 75 of 76 version tags. Nobody noticed for hours.
On March 19, 2026, an attacker force-pushed 75 of 76 version tags in aquasecurity/trivy-action. Every tag — from @v1 to @0.28.0 — now pointed to the attacker's commit. Any CI pipeline referencing trivy-action by version tag was silently running malicious code on its next trigger.
The industry response was "pin your SHA hashes." That's necessary. But it's one layer of a defense that requires several.
We built cilock to provide those layers. Here's how it would have stopped this attack — and what we proved by testing it against the real attack techniques in a live GitHub Actions CI pipeline.
The Attack

Four stages. Minimal footprint. Designed to blend in.
Stage 1: Tag Hijacking
75 of 76 version tags force-pushed. Each malicious commit modified only entrypoint.sh — the rest of the file tree was master HEAD. Commit messages reused legitimate ones. From the outside, nothing looked wrong.
Stage 2: Credential Harvesting
~105 injected lines ran before the legitimate Trivy scan:
- Process environment scraping — Read
/proc/*/environfor every running process, following file paths found in values. - Runner memory extraction — A base64-encoded Python script used
sudoto read/proc/<pid>/memfrom theRunner.Workerprocess, searching for{"value":"<secret>","isSecret":true}— the exact format GitHub Actions uses to store secrets in memory. - Filesystem sweep — 50+ paths: SSH keys, AWS/GCP/Azure credentials, Kubernetes tokens, Docker configs, Terraform state, Solana validator keys, shell histories.
Stage 3: Encryption & Exfiltration
Everything accumulated to /tmp/runner_collected_<pid>.txt, encrypted with AES-256-CBC + RSA-4096 OAEP, bundled as tpcp.tar.gz, and HTTPS POST'd to scan.aquasecurtiy[.]org (typosquat). Fallback: if a GitHub PAT existed, upload as a release asset to a public tpcp-docs repo on the victim's account. On developer machines, a persistent backdoor at ~/.config/systemd/user/sysmon.py polled a blockchain-hosted C2 on the Internet Computer (ICP).
Three Layers That Kill This Attack
SHA pinning stops the tag hijack. But what if the action you pinned to was already compromised? What if a maintainer ships a malicious update you re-pin to? The real fix is defense in depth: prevention, content detection, and behavioral detection. cilock provides all three.
Layer 1: Prevention — Don't Run Untrusted Code
Restrict your actions to an approved catalog: internal forks, Chainguard Actions (hardened third-party), or GitHub's official actions/* namespace. Then enforce it with policy:
package cilock.verify
import rego.v1
approved_sources := ["chainguard-dev/", "your-org/", "actions/"]
deny contains msg if {
not source_approved(input.actionref)
msg := sprintf("Action from untrusted source: %s", [input.actionref])
}
deny contains msg if {
not input.refpinned
msg := sprintf("Action not pinned to SHA: %s", [input.actionref])
}
source_approved(ref) if {
some prefix in approved_sources
startswith(ref, prefix)
}Proven in CI: This policy denied actions/setup-node@v4 with "Action not pinned to SHA" through a full cilock verify pipeline — Fulcio OIDC signing, Sigstore TSA timestamps, signed Rego policy evaluation. Not a local test. A real GitHub Actions workflow.
Layer 2: Content Detection — Catch Credential Leakage
If prevention fails — a trusted source is compromised, a human overrides a check — cilock's secretscan attestor catches credential patterns in the command output. It runs Gitleaks pattern detection on stdout and recursively decodes base64, hex, and URL-encoded content through multiple layers.

Proven in CI: We reproduced the TeamPCP credential harvesting technique — the same base64-encoded stealer pattern, the same credential output format — and ran it through cilock in a live GitHub Actions pipeline. Result: 4 findings ( github-pat and private-key at depth 0, duplicates at depth 1 from the decoded stealer output). --attestor-secretscan-fail-on-detection blocked the build. Clean builds pass. Malicious builds fail.
// scanner.go — recursive multi-layer decode
if currentDepth < a.maxDecodeLayers {
for _, scanner := range defaultEncodingScanners {
candidates := scanner.Finder(contentStr)
for _, candidate := range candidates {
decodedBytes, err := scanner.Decoder(candidate)
if err == nil && len(decodedBytes) >= minSensitiveValueLength {
recursiveFindings, _ := a.scanBytes(
decodedBytes, sourceIdentifier, detector,
processedInThisScan, currentDepth+1,
)
findings = append(findings, recursiveFindings...)
}
}
}
}Layer 3: Behavioral Detection — Catch What the Attacker Does, Not What They Print
The real TeamPCP stealer was designed for covert operation. Credentials went to files, not stdout. Secretscan's content scanning alone would miss it. That's why cilock has --trace.
The --trace flag uses Linux ptrace to intercept syscalls and record every file each process opens. An OPA policy then flags the filesystem access patterns that credential harvesting produces — without needing to see the credential content:
package cilock.verify
import rego.v1
deny contains msg if {
some proc in input.processes
some file in object.keys(proc.openedfiles)
startswith(file, "/tmp/runner_collected")
msg := sprintf("Credential harvesting: %s (PID %d) opened %s",
[proc.program, proc.processid, file])
}
deny contains msg if {
some proc in input.processes
some file in object.keys(proc.openedfiles)
file == "/proc/self/environ"
msg := sprintf("Env scraping: %s (PID %d) read %s",
[proc.program, proc.processid, file])
}Proven in CI: We reproduced the covert exfiltration pattern — credentials written only to files, nothing sensitive to stdout, exactly how the real stealer operated. Secretscan found zero findings. cilock verify with the behavioral policy denied — flagging /usr/bin/cat opening /tmp/runner_collected_2325.txt and reading /proc/self/environ. The covert attack that evaded content scanning was caught by behavioral policy. Trace overhead: ~36% on an npm install workload (5.1s → 6.9s).
Content scanning catches what the attacker prints. Behavioral detection catches what the attacker does. Prevention stops the attacker from running at all.
Cryptographic Verification — Not Just Logging
Every cilock attestation is signed with Fulcio OIDC (short-lived certificates tied to GitHub Actions identity), timestamped by Sigstore TSA (RFC 3161), and verified against a signed Rego policy. cilock verify validates the signature chain, checks the timestamp, and evaluates the policy. If anything fails, the release is blocked.
This is not audit logging. This is cryptographic proof of what ran, when it ran, what it produced, and whether it met policy — with a tamper-evident chain from the CI runner to the policy decision.
// SHA pinning check — cilock-action records this in every attestation
func isRefPinned(ref string) bool {
atIdx := strings.LastIndex(ref, "@")
if atIdx < 0 { return false }
sha := ref[atIdx+1:]
if len(sha) != 40 { return false }
_, err := hex.DecodeString(sha)
return err == nil
}When an action ref isn't SHA-pinned, cilock emits a GitHub Actions annotation warning and records refpinned: false (omitted when false via Go omitempty) in the attestation for downstream policy enforcement.
With and Without cilock

Without cilock
- Attacker force-pushes malicious tag
- CI executes blindly — no source verification
- Secrets exfiltrated to attacker domain
- Nobody knows until someone checks the source
- No forensic evidence of what ran or what was stolen
With cilock
- Source policy blocks unapproved actions before they run
- SHA pinning enforced — tag rewrite has no effect
- Secretscan catches credential patterns in output
- Trace + OPA catches covert file-based credential harvesting
- Signed attestation creates tamper-evident audit trail
- Policy enforcement blocks the release
What cilock Does Not Do
We'd rather you know upfront than discover in production.
- Detection is post-execution. cilock wraps the action and scans its output after it runs. If secrets are exfiltrated during execution, the exfiltration has already happened. cilock blocks the release and provides forensic evidence, but cannot prevent the initial exfiltration. Prevention layers are the first line of defense.
- No network egress monitoring. The HTTPS POST to the attacker's C2 domain would not be detected. StepSecurity Harden-Runner covers this gap.
- Trace requires opt-in. Behavioral detection needs
--traceenabled and OPA rules defined. Without it, covert file-based attacks evade secretscan's content scanning. - Novel exfiltration techniques can evade pattern matching. Secretscan uses Gitleaks rules. A technique that doesn't match known credential patterns would evade content detection. Behavioral detection (trace + OPA) covers many of these cases by catching the filesystem access patterns rather than the content.
Everything in this article was tested in a public test repository with live GitHub Actions workflow runs. Three tests, each proving a different layer:
- Attack reproduction — reproduces the TeamPCP credential harvesting technique (stdout + base64 stealer). Proves secretscan catches it.
- Covert variant — reproduces the file-based exfiltration pattern (nothing to stdout). Proves trace + OPA catches it.
- Real Trivy scan — wraps actual
trivy imagewithcilock runagainst a Docker image (12 CVEs found). Proves cilock works on production workloads, not just attack reproductions.
All verified end-to-end with Fulcio OIDC + Sigstore TSA. The code, the policies, and the workflow are all in the repo. Run it yourself.
Pinning is a lock. Attestation is a security camera, a receipt, and a notary.
Try cilock on your pipeline — it's open source.
github.com/aflock-ai/cilock-action
We're at RSAC 2026 — NXT-1, Early Stage Expo. We'll run cilock on your pipeline live. Bring your repo URL.
Or start now at testifysec.com — free trial, no credit card required.