open atlas
↑ Back to track
CI/CD pipelines CICD · 05 · 02

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.

CICD Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

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.sh

Automate 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.

ControlFailure it preventsConcrete mechanism
Pin actions by SHAA trusted tag force-moved to malicious code (tj-actions)uses: org/action@<40-char-sha> + Dependabot/Renovate to bump it
Secret scanning + push protectionA hardcoded key committed into historyBlock the push before it lands; alert on anything that slipped past
Least-privilege tokenA malicious dep using a write-scoped token to push or publishTop-level permissions: { contents: read }; widen per-job only
OIDC short-lived credsA leaked long-lived cloud key usable until rotatedid-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.

Pick the best fit

A team adopts a popular third-party GitHub Action across all pipelines that handle deploy secrets. How should they reference it?

Quiz

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?

Quiz

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?

Recall before you leave
  1. 01
    Why is `uses: actions/checkout@v4` a weaker pin than `@<full-sha>`, and how did the tj-actions incident prove it?
  2. 02
    How do least-privilege `permissions:` and OIDC each reduce the blast radius of a compromised CI dependency?
Recap

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.

recallapplystretch0 of 5 done

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.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.