Short version: pinning uses: to a full 40-character SHA stops a maintainer rewriting a tag under you, and it does nothing about a commit that entered the upstream repository legitimately. That is the path ChainDrop took on 4 August 2026 into packages with roughly two billion monthly installs, carrying cryptographically valid provenance the whole way. The ten checks below cover the rest; the first five are a week of existing-staff work.
The reader I have in mind is the person who has to write the answer down. A customer security questionnaire asks whether third-party CI code is pinned and verified. An auditor asks for the artifact that enforces it. An incident channel asks whether a compromised package version was ever resolved into a build. Datadog's State of DevSecOps 2026 found 71% of organisations leave GitHub Actions completely unpinned and only 4% pin every public action to a commit hash, so most teams are answering those three questions from memory. The same trust assumption bites outside CI too: the arrayref attack showed cargo's build.rs runs any code the moment a legitimate version bump lands.
Here is the path that beat the pin.
Datadog Security Labs timestamped the first unsigned commit at 09:02:37 UTC and the malicious publish at 09:35:00 UTC, a 32-minute window (full writeup). Snyk's analysis of the same compromise states the mechanism directly: the malicious source was present in the tagged repository state, so the legitimate release workflow built and attested the malicious artifact.
Before the list, the mapping I ask every team to keep next to their workflows:
| Control | What it asserts | What it is silent on |
|---|---|---|
| SHA pin | The bytes behind this ref did not change under a tag | Whether the commit was reviewed, or lives in the repo you think it does |
actions.lock | The pinned commit is reachable from a branch in the named repo | Actions reached via reusable workflows in other repositories |
| npm provenance | Which workflow, at which commit, built the tarball | Whether that commit was authorised |
gh attestation verify --signer-workflow | A named workflow file produced this artifact | What that workflow's own dependencies did at runtime |
| Egress allowlist | Where the runner is permitted to talk | What the job does with secrets it already holds |
Ten checks, and what each one actually proves
- Confirm every pinned SHA lives in the upstream repository, not in a fork of it. GitHub resolves a commit against the shared object database of the entire fork network, so a hex string from anyone's fork answers a request to
actions/checkout. Alex Vaines demonstrated in March 2026 that a pull request can keepuses: actions/checkout@unchanged, swap only the hex, and execute forked code while the reviewer sees an ordinary SHA bump (the write-up). zizmor has an audit for exactly this substitution:
zizmor --min-severity=medium .github/workflows/
The finding you want is impostor-commit. Treat a hit as an incident, since there is no benign reason for a fork-only commit to appear in a uses: line.
- Adopt the Actions lockfile while it is still a preview. GitHub's
gh-actions-lockextension writes.github/workflows/actions.lockand enforces check 1 at runtime: a locked action must have a branch the commit is reachable from, and repository redirects are refused.
gh extension install github/gh-actions-lock
gh actions-lock
It is pre-1.0, the file format can change between releases, and it skips local ./ action references, so onboard one low-traffic repository first and read the extension's own README before you wire it into required checks.
- Inventory unpinned uses across the org, then state what the inventory misses. Two blind spots survive a per-repo scan: Docker-based actions pinned by image tag, and actions pulled in by reusable workflows that live in a different repository. zizmor's
unpinned-imagesaudit covers the first; nothing covers the second automatically, so enumerate callers by hand.
gh repo list ORG --limit 1000 --json nameWithOwner --jq '.[].nameWithOwner' \
| xargs -I{} gh api "repos/{}/contents/.github/workflows" --jq '.[].name' 2>/dev/null
This is the same discipline as generating an SBOM with Syft and filtering Grype with VEX: the list is only useful once you can say out loud which components it never sees.
- Make
gh attestation verifyassert a specific signer. The bare two-argument form accepts any workflow in any repository under the owner you named, which means an artifact built by a workflow nobody reviewed still passes. The flags that turn it into a gate, per the gh manual:
gh attestation verify ./dist/app.tar.gz --owner ORG \
--signer-workflow ORG/platform/.github/workflows/release.yml \
--predicate-type https://slsa.dev/provenance/v1
Check your CI logs for the short form today. In my experience it is usually there, copied from a quickstart during the week someone first enabled attestations.
- Grep for the developer-machine execution paths, because the package manager stopped being the only one. The keyv payload shipped a
.vscode/tasks.jsonwith"runOn": "folderOpen"and a.claude/settings.jsonSessionStart hook, both invokingsetup.mjs, documented by Snyk. Opening the folder was sufficient; no install required.
rg -n --hidden -g '**/.vscode/tasks.json' -g '**/.claude/settings.json' \
-e '"runOn"[[:space:]]*:[[:space:]]*"folderOpen"' -e '"SessionStart"' .
Add both paths to CODEOWNERS in every repository. Ten minutes of work buys a permanent review requirement on the two files nobody reads in a diff. The install-time half of this problem is separate, and npm's allowScripts lives in the package.json schema rather than .npmrc, which trips up most teams enabling it for the first time.
- Split build from publish and put a human on the publish environment. Keep
permissions: id-token: writein the publish job alone, then gate that job behind a GitHub Environment with required reviewers. A compromised push to the default branch then stops short of minting a registry token. The cost is honest: every release waits on an approver, which is a release-engineering trade rather than a free win. The token-scoping half of this is covered in the notes on GitHub Actions OIDC to AWS. - Ask provenance only which workflow built the tarball. npm provenance proves the workflow identity and the source commit, and it says nothing about whether that commit was authorised, which is precisely why the ChainDrop artifacts verified. Budget the authorisation question separately through install-time behavioural scanning or a cooldown window before new versions are allowed to resolve. Route on provenance, decide on behaviour.
- Put hosted runners behind an egress allowlist you control now. GitHub's Actions security roadmap, published 26 March 2026, lists a native egress firewall for hosted runners alongside dependency locking, scoped secrets and an immutable Actions data stream, and most of it is still preview. ChainDrop anchored its command-and-control in an Ethereum smart contract so that domain blocklists would rot, per Datadog, which settles the allowlist-versus-denylist argument for CI. Run one repository in block mode for two weeks and expect roughly a week of tuning; the default-deny egress pattern for Kubernetes pods transfers almost directly.
- Move the pinning rule into org-level Actions policy. GitHub Actions policy has supported blocking specific actions and requiring SHA pinning since the 15 August 2025 changelog. Policy is an artifact an auditor accepts; a wiki page and a review convention are not. New repositories inherit the setting at creation, which is the part that survives your attention moving to something else.
- Rehearse the "were we affected" answer against a real indicator set. Datadog published a stage-two SHA-256 of
9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc, the contract address0xE1f2395ee43e45A1556EC6438a88c31B83493103, and repositories namedShai-Hulud: Here We Go Again. Test the lookup before an incident channel is waiting on it:
npm ls keyv flat-cache file-entry-cache cacheable-request cacheable \
@cacheable/utils cache-manager ecto --all
The blind spot, stated plainly: that reads today's tree. Reconstructing which version a build resolved six months ago depends on retained run logs, and default Actions retention expires well before a slow-burn compromise surfaces. Anything a compromised runner could reach counts as exposed, including every secret your OIDC trust policies hand out at job start.
Wrap-up
The habit that carries the rest: write one sentence next to each control saying what it asserts. "Pinned by SHA" asserts the bytes did not change under a tag. "Provenance verified" asserts which workflow built the artifact. Neither sentence contains the word "safe" or the word "reviewed", and the distance between what you believe you proved and what you actually proved is where the ChainDrop consumers were standing on 4 August.
The counterargument deserves airtime. Maintainers who resist pinning point out that a fully pinned fleet stops receiving security patches the moment Dependabot coverage lapses, and stale pins have burned plenty of teams. That position buys real patch velocity, and it costs the ability to answer question one at all. Pick deliberately and record which you picked, with a date.
Tips 1 through 5 fit inside a week with the staff you have. Tips 6 through 10 change release process, spend money on egress tooling, and need an owner for the evidence trail afterwards. When the second half keeps getting deferred, a scoped pipeline-provenance review is the usual way in: inventory every uses: across the org, verify each pin against its upstream branch, produce the attestation policy your questionnaire answers can point at, and hand back the gap list with a cost attached to each fix. It will not make the finding disappear by itself. Someone still has to approve the release gate.
Comments
Be the first to comment.