Dependency and secret hygiene: hardening CI itself
The CI runner holds every secret you give it, so harden CI itself: pin actions by full commit SHA (a tag can be moved to malicious code), scan and block leaked credentials at push, set permissions to least-privilege, and prefer short-lived OIDC creds over stored keys.
On 14 March 2025, an engineer at one of ~23,000 repositories pushed a routine commit. Their workflow referenced a wildly popular helper, tj-actions/changed-files@v35 — a mutable tag. Hours earlier, an attacker had force-moved every version tag on that action, from v1 up, to a single malicious commit. The injected code base64-decoded a payload that dumped the runner’s memory — AWS keys, npm tokens, GitHub PATs, private RSA keys — straight into the build log, where it sat in plain text for anyone who could read Actions output. Nobody changed their workflow. The tag they trusted yesterday pointed at attacker code today. Repos that had pinned the action to a full commit SHA ran the old, safe code and were untouched.
Pinning: a tag is a promise, a SHA is a fact
Your application’s lockfile (package-lock.json, pnpm-lock.yaml, poetry.lock) pins the exact byte-content of every dependency by integrity hash, so npm ci installs the same tree on every machine. That discipline is well understood. The blind spot is the other dependencies your pipeline pulls: the GitHub Actions themselves. Writing uses: actions/checkout@v4 looks like a version pin, but v4 is a mutable git tag — a label the maintainer (or anyone who compromises them) can force-move to a different commit at any time, with no change to your repository. You are not pinning code; you are pinning a promise to keep pointing the tag at good code.
A full commit SHA is the opposite: it is the content-address of an exact tree, and git’s object model means you cannot move it without breaking the hash. uses: actions/checkout@<40-char-sha> runs the same bytes forever. This is the single control that would have neutralised the tj-actions blast radius — the malicious code lived behind the tags, so every SHA-pinned consumer kept running the old, safe commit. Pin third-party actions, and the riskiest first-party ones, by full-length SHA, with a trailing comment recording the human-readable version:
# BAD — mutable tag, default (often broad) token, secrets exposed to everything
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # @v4 can be force-moved to malicious code
- uses: tj-actions/changed-files@v35 # the exact action whose tags were repointed
- run: ./deploy.sh # AWS_KEY in env, available to every step above
# GOOD — pinned by full SHA, least-privilege token, OIDC only where needed
on: push
permissions:
contents: read # default everything to read-only
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # grant OIDC ONLY in the job that needs it
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::111122223333:role/ci-deploy # short-lived, no stored key
aws-region: eu-central-1
- run: ./deploy.shAutomate updates, or pinning rots into stale CVEs
Pinning by SHA buys security at the cost of toil: a frozen digest never picks up the upstream bugfix or the patch for the next CVE. Left manual, “pin everything” decays into “run vulnerable versions forever.” The resolution is to let a bot do the bumping. Dependabot and Renovate both open pull requests that update pinned dependencies — including action SHAs — and, configured well, they update the SHA and the human-readable version comment together. Group related updates so you review one PR instead of forty; schedule them weekly so they do not interrupt feature work; and reserve auto-merge for genuinely low-risk classes only — a patch bump with passing CI and an unchanged maintainer. Renovate has auto-merge built in (automerge: true in a package rule); Dependabot needs a small Actions workflow using dependabot/fetch-metadata to gate gh pr merge --auto on the update type. The principle is the same: machine bumps the digest, CI proves it still builds, a human approves anything non-trivial.
▸Why this works
Auto-merging a minor or major update — or any update where the maintainer changed — is exactly the gap a supply-chain attacker wants. A package handed to a new owner, or a dependency that quietly added a postinstall script, sails straight to main if your auto-merge rule is too generous. Keep auto-merge to patch-level, tests-green, same-maintainer, and require human eyes on everything else.
Secret scanning and push protection: stop the leak at the door
Pinning protects you from other people’s compromised code; the next layer protects the pipeline from your own team’s mistakes. The most common leak is mundane: someone hardcodes an API key, commits it, and it lives in git history forever (and in every fork and clone). Secret scanning matches pushed content against a large library of known credential formats — cloud keys, tokens, connection strings — and alerts on anything detected. Push protection is the proactive half: it inspects the content during the push and blocks the commit before it ever reaches the remote when it matches a high-confidence pattern, so the secret never enters history in the first place. The author either removes it or, rarely, records an explicit justification to bypass. Treating a blocked push as the normal outcome — not an obstacle to route around — is the cultural shift that keeps credentials out of the repo.
Least privilege and short-lived creds: shrink the blast radius
Even a perfectly pinned, leak-free pipeline must assume some dependency will eventually turn malicious. Least privilege is what bounds the damage when it does. Two levers matter most. First, the workflow token: GitHub’s GITHUB_TOKEN has defaulted to read-only since February 2023, but many repos still run with broad write scopes. Declare permissions: explicitly at the top of the workflow, default everything to read, and grant write scopes only on the specific job that needs them — so a malicious dependency running in your test job cannot push to main or publish a package, because the token in that job simply lacks the scope. Second, cloud credentials: a long-lived AWS_SECRET_ACCESS_KEY sitting in repo secrets is a permanent prize — leak it once (to a log, to a compromised action) and it works until someone notices and rotates it. OIDC replaces it with a short-lived, audience-scoped token minted per run: the workflow requests an OIDC token (id-token: write), exchanges it with the cloud provider, and receives credentials that expire in minutes and were never stored anywhere. Nothing durable to steal.
| Control | Failure it prevents | Concrete mechanism |
|---|---|---|
| Pin actions by SHA | A trusted tag force-moved to malicious code (tj-actions) | uses: org/action@<40-char-sha> + Dependabot/Renovate to bump it |
| Secret scanning + push protection | A hardcoded key committed into history | Block the push before it lands; alert on anything that slipped past |
| Least-privilege token | A malicious dep using a write-scoped token to push or publish | Top-level permissions: { contents: read }; widen per-job only |
| OIDC short-lived creds | A leaked long-lived cloud key usable until rotated | id-token: write → exchange for minutes-long creds, nothing stored |
These four controls defend at four different points of failure. Together they mean that even when one fails — a dependency slips through scanning, a token scope is wider than ideal — the others still bound the blast radius. Without any one of them, a single breach unravels the chain.
A team adopts a popular third-party GitHub Action across all pipelines that handle deploy secrets. How should they reference it?
Your workflow uses `actions/checkout@v4`. Overnight, with no change to your repo, the action's behaviour becomes malicious and dumps your secrets to the log. How is that possible?
You want to deploy to AWS from CI without a long-lived `AWS_SECRET_ACCESS_KEY` stored in repo secrets. What is the senior approach?
- 01Why is `uses: actions/checkout@v4` a weaker pin than `@<full-sha>`, and how did the tj-actions incident prove it?
- 02How do least-privilege `permissions:` and OIDC each reduce the blast radius of a compromised CI dependency?
The CI runner is a high-value target: it holds every credential you hand it, so the environment itself must be hardened, not just the app it builds. Start with pinning. Your lockfile already content-pins app dependencies, but GitHub Actions are dependencies too, and @v4 is a mutable tag that can be force-moved to malicious code — the exact vector that turned tj-actions/changed-files into a secret-exfiltrating payload across ~23,000 repositories in March 2025, while SHA-pinned consumers were untouched. So pin actions by full commit SHA, and let Dependabot or Renovate open PRs to bump those SHAs (auto-merge only patch-level, tests-green, same-maintainer) so security and freshness coexist. Next, stop your own leaks: secret scanning alerts on committed credentials and push protection blocks them before they ever enter history. Then bound the damage of whatever still gets through with least privilege — declare permissions: explicitly, default to read-only, and widen write scopes only on the one job that needs them, so a malicious dependency can’t push or publish. Finally, replace long-lived stored cloud keys with OIDC: a short-lived, per-run token (id-token: write) leaves nothing durable to steal. Pin, scan, restrict, expire — four layers, none sufficient alone, defence in depth for the pipeline. Now when you open a workflow file and spot uses: some-action@v3, your first question should be: is this pinned to a full commit SHA, and does a bot bump that SHA automatically?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.