GitGuardian counted 33,185 unique secrets in the 20,649 public repositories Shai-Hulud 2.0 had created by 24 November 2025. Every one of those secrets sat somewhere a compromised process could read it. Part 1 covered how attacker code gets into a workflow. This post assumes some of it will get in anyway and cuts down what it finds there.

Cut the default token down

Every job gets a GITHUB_TOKEN whether it asks for one or not. The token is scoped to the repository and expires when the job ends. A repo setting decides its default permissions. Repos and orgs created before February 2023 default to read-write, so any compromised step in them can push commits straight to an unprotected branch.

Declare permissions at the top of every workflow and widen them per job:

permissions: {}

jobs:
  test:
    permissions:
      contents: read
  release:
    permissions:
      contents: write

Then set the repo or org default to read-only, so anything you missed fails closed. GitHub Security Lab publishes an action that records which permissions a workflow uses at runtime, which helps size the block for existing workflows.

contents: write grants more than its name suggests. It covers pushing commits and also replacing the binaries attached to releases.

actions/checkout writes the token into .git/config by default so later steps can push. Every later step, third-party actions included, can read it from that file. Set persist-credentials: false unless a later step pushes. Code running in the job can still pull the token from memory, so this narrows the exposure without removing it.

Swap long-lived cloud keys for OIDC

An access key stored as a repo secret works from anywhere until someone rotates it. Once a team owns a few hundred of them, nobody rotates them.

GitHub mints a signed OIDC token for each job, with claims naming the repo, branch, environment, and workflow. AWS, GCP, and Azure exchange that token for short-lived credentials. npm and PyPI accept it for trusted publishing. The workflow stores no secret at all:

permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@<sha> # v4
    with:
      role-to-assume: arn:aws:iam::123456789012:role/deploy-prod
      aws-region: eu-central-1

The IAM role’s trust policy decides which tokens it accepts:

"Condition": {
  "StringEquals": {
    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
    "token.actions.githubusercontent.com:sub": "repo:my-org/my-service:environment:production"
  }
}

A trust policy that checks only aud accepts a token from any repository on GitHub. A StringLike on repo:my-org/* accepts any branch of any repo in the org, including a feature branch someone pushed five minutes ago. Pin sub to one repo and one environment or branch.

Don’t rely on log masking

GitHub redacts a secret from logs only when it appears exactly as stored. A base64 copy, a URL-encoded copy, or a JWT built from the secret all print in the clear, which is why the tj-actions payload base64-encoded what it dumped. Register any value derived from a secret as a secret too, or mask it at runtime with echo "::add-mask::$VALUE". Store one value per secret, because GitHub can’t reliably redact a key buried inside a JSON or YAML blob. If a secret does reach a log, delete the log and rotate the secret.

Scope secrets with environments

Secrets that have to stay, and OIDC roles, should be reachable only from the jobs that need them. GitHub environments handle this. Put production secrets in a production environment, restrict it to the protected main branch, and add required reviewers if deploys should wait for a human. A job on a feature branch then can’t read them, and neither can an attacker who compromises that job. Naming the environment in the OIDC sub claim, as above, makes the cloud role refuse jobs outside that environment too.

Enforce it at the org level

Everything above relies on every engineer remembering it in every workflow. GitHub’s Actions policy settings at the enterprise, org, and repo level enforce part of it for you. The defaults are the loosest options on offer. All actions are allowed, no pinning is required, and older orgs hand out read-write tokens. I’d check four settings:

  • Allow only actions from your own org, from GitHub, and from an explicit allowlist. A new third-party action then fails until someone reviews it.
  • Require actions to be pinned to a full-length commit SHA. A workflow that references an action by tag fails.
  • Set the default GITHUB_TOKEN permission to read-only.
  • Leave “Allow GitHub Actions to create and approve pull requests” switched off. New orgs ship with it off. With it on, a compromised job can use the workflow token to approve an attacker’s pull request and satisfy required review.

Update, 2 November 2026. A “Require lockfile” policy shipped in public preview alongside workflow lockfiles. Enterprise, org, and repo admins can use it to make lockfiles mandatory. Dependencies recorded in a lockfile also satisfy the SHA-pinning requirement without any change to the workflow file.

Lint workflows with zizmor

A linter catches the patterns no setting can block. actionlint only checks that a workflow is valid. Checkov has a few basic Actions checks. CodeQL, free for public repos, scans workflow files for injection and missing permissions. zizmor was built for this job and covers most of the workflow patterns in this series. It flags unpinned actions, template injection, dangerous triggers, broad permissions, persisted credentials, cache use in release workflows, and Dependabot configs without a cooldown. Given a GitHub token, it also checks your pinned actions against known-vulnerable versions.

zizmor .github/workflows/
zizmor --persona=pedantic .github/workflows/

The default persona reports likely vulnerabilities. Pedantic adds stricter checks, such as asking you to justify every write permission and to set a concurrency limit on jobs that hold one. A config file suppresses individual rules or trusts specific actions by tag, such as GitHub’s own actions, which now ship as immutable releases.

What’s next

These fixes harden how workflows are written. Branch protection and CODEOWNERS on .github/workflows/ stop a workflow change from landing without review. Part 3 covers the machines the jobs run on, and the audit log events that would have exposed Shai-Hulud 2.0’s runner backdoor.