Part 5 of 849 min read · 9 diagramsAI-assisted

GitHub Security & Governance

Table of Contents#

  1. Why This Gets Its Own Chapter
  2. GITHUB_TOKEN — Default Permissions and Least Privilege
  3. GitHub Apps vs. Personal Access Tokens vs. GITHUB_TOKEN
  4. Pinning Actions to a Commit SHA — Supply Chain Risk
  5. Vetting a Third-Party Action Before Adopting It
  6. OIDC — Eliminating Long-Lived Cloud Credentials
  7. A Full Worked OIDC Example: Deploying to AWS
  8. OIDC to GCP and Azure — the Same Pattern, Different Trust Store
  9. Secrets Management — Repo, Environment, and Organization Levels
  10. Branch Protection Rules vs. Rulesets
  11. Signed Commits
  12. CODEOWNERS — Enforcing Review Ownership
  13. Dependabot — Automated Dependency Updates
  14. GitHub Advanced Security — CodeQL and Secret Scanning
  15. Artifact Attestations — Build Provenance and SLSA
  16. Scanning and Signing Container Images in the Pipeline
  17. Spending Limits and Abuse Prevention
  18. Organization-Level Security Controls
  19. Compliance Frameworks and GitHub — What Auditors Actually Ask For
  20. Self-Hosted Runner Security in Depth
  21. The pull_request_target Trap
  22. Responding to a Compromised Pipeline or Leaked Secret
  23. Tying It Together — Defense in Depth Across the Pipeline
  24. A Worked Example: Verifying Everything Together at Deploy Time
  25. Quick Reference: Security Checklist
  26. Common Mistakes
  27. Worked Practice Problems
  28. Summary and What's Next

Why This Gets Its Own Chapter#

Part 4 built a working GitHub Actions pipeline — but a pipeline that runs is not automatically a pipeline that's safe to run. A CI/CD system is, by its very nature, a machine that has write access to your codebase, read access to your secrets, and the ability to execute arbitrary code on every pull request — which makes it one of the highest-value targets in the entire software supply chain. This isn't theoretical: real, publicized supply-chain attacks (compromised third-party Actions, secrets exfiltrated via a malicious PR) have hit production GitHub Actions pipelines. This chapter is the hardening pass every pipeline from Part 4 needs before it's trusted with real production secrets.

Diagram

This directly reuses the shift-left principle from the DevSecOps series covered earlier in this course — securing the pipeline itself, not just what the pipeline scans, is exactly the kind of "push security earlier" thinking that series argued for.

A pattern worth naming explicitly, because it recurs across nearly every real CI/CD supply-chain incident regardless of the specific platform or attacker: the pipeline is attacked not because it's the final target, but because it's the highest-leverage stepping stone to the final target. An attacker rarely wants "access to a CI runner" for its own sake — they want the production database it can reach, the cloud account it can deploy into, or the customer-facing package registry it can publish to. This reframing is why this entire chapter treats the pipeline's own credentials, not just its build logic, as the primary thing to defend: a pipeline that only ever builds and tests code but holds broad production credentials "just in case" is a far more attractive target than one that genuinely can't reach anything sensitive without an additional, deliberate, auditable step.


GITHUB_TOKEN — Default Permissions and Least Privilege#

Every workflow run automatically gets a short-lived, auto-generated token called GITHUB_TOKEN, scoped to the triggering repository, valid only for the duration of the job. It's how steps like actions/checkout@v4 authenticate to clone the repo, and how a workflow can call the GitHub API (open an issue, post a PR comment) without any manually-configured credential.

The default permission level for GITHUB_TOKEN depends on a repository/organization setting, and on many repos defaults to broad read-write access across almost every API scope (issues, PRs, packages, deployments) — far more than most jobs actually need. The fix is explicit, minimal permissions: declarations:

# At the WORKFLOW level — sets the default ceiling for every job in this file
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    # inherits contents: read from the workflow level — sufficient for checkout+build
    steps: [...]

  comment-on-pr:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write        # escalated ONLY in this specific job, ONLY the scope it needs
    steps:
      - run: gh pr comment ${{ github.event.pull_request.number }} --body "Build passed"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Diagram

The security principle this enforces is the same least-privilege idea already established for IAM roles in the Part 2 IaC chapter and for service accounts in the Kubernetes deep-dive elsewhere in this course — a credential should be able to do the minimum required for its specific job, so that a compromised job (via a malicious dependency, a supply-chain attack in a third-party Action) has the smallest possible blast radius. A job that only needs to read code and run tests should never hold a token that could also push a release or modify repo settings.


GitHub Apps vs. Personal Access Tokens vs. GITHUB_TOKEN#

A pipeline sometimes needs to authenticate to GitHub's API as something other than the auto-generated GITHUB_TOKEN — most commonly when an action needs to trigger another workflow (the default GITHUB_TOKEN deliberately cannot do this, to prevent runaway recursive workflow loops) or needs permissions that persist across repos. Three mechanisms exist, and picking the wrong one is a common source of both security gaps and confusing "why won't this trigger" bugs.

GITHUB_TOKENPersonal Access Token (PAT)GitHub App installation token
Tied toThe workflow run itselfAn individual human's accountAn installed App, not a person
LifetimeAuto-expires at job endLong-lived (classic) or configurable expiry (fine-grained)Short-lived, auto-refreshed
ScopeSingle repo, auto-configuredWhatever the creating user grants — can span many repos/orgsPrecisely whatever the App was granted at install time
Can trigger other workflows?No (anti-recursion default)YesYes
Survives the creating person leaving?N/A (ephemeral)No — tied to their account, breaks if they leave/are offboardedYes — owned by the organization, not a person
Recommended forThe default for almost everything in Parts 4-5Rarely, for genuinely personal automationOrg-wide automation, bots, cross-repo tooling

The practical guidance, in order of preference: use the default GITHUB_TOKEN for anything scoped to a single repo's own workflow. Reach for a GitHub App installation token (via actions/create-github-app-token, a well-maintained community Action) when a workflow genuinely needs to trigger another workflow, or needs access spanning multiple repositories — it doesn't tie the automation's continued functioning to one employee's account, and its permissions are auditable and precisely scoped at the organization level. Avoid classic Personal Access Tokens for pipeline automation entirely where possible; a long-lived, broadly-scoped PAT stored as a secret has essentially the same "leaks forever until manually rotated" risk profile as the long-lived cloud credentials OIDC exists to eliminate later in this chapter.


Pinning Actions to a Commit SHA — Supply Chain Risk#

uses: actions/checkout@v4 looks precise, but a tag like v4 is mutable — the maintainer (or, in a supply-chain compromise, an attacker who gained control of the maintainer's account) can move that tag to point at different code at any time, and every workflow using @v4 picks up the new code on its very next run, with zero review. This exact attack pattern — a popular Action's tag hijacked to inject credential-stealing code — has happened in the real world.

# Mutable — the actual code that runs can change without your knowledge or review
- uses: actions/checkout@v4

# Pinned — this EXACT commit's code runs, always, until you deliberately bump it
- uses: actions/checkout@8459bea77ac68e6fbdb4b1c8e5d5e5c2e8d1f234    # v4.1.7

Pinning to a full commit SHA is the only way to guarantee the code that runs today is the exact same code that ran yesterday — a tag or even a branch name gives an attacker a mutable target; a commit SHA is immutable by Git's own design (changing what a SHA points to would change the SHA itself). GitHub's own security guidance and every major CI-security vendor recommend SHA-pinning for any Action outside your own organization, especially ones handling secrets or running on every PR.

The practical friction this creates, and the tooling that solves it: SHA-pinned workflows lose the human-readable version number, and manually tracking updates for dozens of pinned Actions across many workflow files doesn't scale. Two standard mitigations:

  1. A trailing comment noting the human version (@8459bea... # v4.1.7, as shown above) so a reader isn't left guessing what a 40-character hash actually is.
  2. Dependabot version updates (distinct from Dependabot's security-alert feature, covered later) configured for the github-actions ecosystem — it automatically opens a PR bumping a pinned SHA to a newer release, with the actual code diff available for review before merge, restoring the safety of a controlled, reviewable update instead of an automatic, unreviewed one.
# .github/dependabot.yml — keeps pinned Action SHAs current via reviewable PRs
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

Vetting a Third-Party Action Before Adopting It#

SHA-pinning (previous section) protects against an Action changing after you've adopted it — it does nothing to protect against adopting something already malicious or poorly maintained in the first place. A short checklist worth applying before adding any new uses: line from outside your own organization:

  1. Is it published by GitHub itself, or verified in the Marketplace? Actions under the actions/ org (e.g. actions/checkout) or carrying a Marketplace "verified creator" badge have gone through at least some baseline vetting — not a guarantee, but a meaningfully different starting trust level than an unverified individual's repo.
  2. Check the source, not just the README. For anything handling secrets or running on every PR, actually read the Action's action.yml and its entry-point script — a composite or JavaScript action's real behavior is fully visible source, unlike a compiled binary; there's no excuse not to look before trusting it with production credentials.
  3. Check maintenance signal. Recent commits, responded-to issues, a reasonable number of stars/forks/dependents — an abandoned Action with no recent activity is a higher risk of being quietly taken over (the exact "maintainer account compromised, tag hijacked" attack pattern this chapter already covered) since nobody is actively watching it.
  4. Prefer fewer, well-known dependencies over many niche ones. Every additional third-party Action in a workflow is an additional trust boundary and an additional thing SHA-pinning and Dependabot maintenance now has to track — a workflow with 15 obscure Actions has a meaningfully larger attack surface than one built from a handful of well-known, actively maintained ones plus some inline run: shell steps.
  5. For anything genuinely high-stakes (a production deploy job, anything touching cloud credentials), consider whether the functionality is simple enough to just write as an inline run: step instead of pulling in a third-party Action at all — the smallest possible trust surface is no external dependency.

OIDC — Eliminating Long-Lived Cloud Credentials#

Before OIDC, deploying from GitHub Actions to a cloud provider (AWS, GCP, Azure) meant storing a long-lived cloud access key as a GitHub secret — a static credential that, if ever leaked (a misconfigured log, a compromised dependency, a leaked secret in a fork), remains valid and exploitable until someone notices and manually rotates it.

OpenID Connect (OIDC) replaces that static credential with a short-lived, cryptographically-signed identity token that GitHub mints fresh for every single workflow run, which the cloud provider verifies and exchanges for temporary, scoped credentials — no long-lived secret stored anywhere, ever.

Diagram

Why this is a categorically different security posture, not just a convenience improvement: a leaked long-lived AWS key is valid until someone notices and rotates it — potentially days or weeks of exposure. A leaked OIDC-derived credential is valid for roughly an hour and, critically, cannot be regenerated by an attacker outside of GitHub's own token-issuance flow — there's no static secret to steal in the first place, because none exists.

The trust relationship is configured entirely on the cloud provider's side (an IAM role's trust policy in AWS, a Workload Identity Federation pool in GCP, a federated credential in Azure), scoped as tightly as the JWT's claims allow — commonly restricted to a specific repository, branch, and even a specific GitHub Environment, so that even a compromised workflow in the right repo but on the wrong branch is rejected before it ever obtains real cloud credentials.


A Full Worked OIDC Example: Deploying to AWS#

Step 1 — one-time AWS setup (outside the workflow, typically done via Terraform per Part 2's IaC conventions): create an IAM OIDC identity provider trusting token.actions.githubusercontent.com, and an IAM role with a trust policy restricting exactly which repo/branch/environment may assume it:

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
    },
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:environment:production"
    }
  }
}

Step 2 — the workflow itself, using the official aws-actions/configure-aws-credentials Action to handle the OIDC exchange:

permissions:
  id-token: write        # REQUIRED — grants this job permission to request an OIDC token at all
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1
          # NOTE: no access key, no secret key, anywhere in this file
      - run: aws s3 sync ./dist s3://my-production-bucket

The permissions: { id-token: write } line is easy to miss and, when missing, fails with a confusing error — without it, the job has no ability to request an OIDC token from GitHub at all, regardless of how correctly the AWS side is configured. This is the single most common first-time OIDC setup mistake.

Notice the trust policy's sub condition scopes down to environment:production specifically — meaning this exact IAM role can only be assumed by a workflow run that is targeting the production GitHub Environment (which, per Part 4, also means it already passed any required-reviewer approval gate). A workflow run on a feature branch, even in the same repo, is cryptographically rejected by AWS before ever obtaining credentials — defense in depth on top of GitHub's own branch protection.


OIDC to GCP and Azure — the Same Pattern, Different Trust Store#

The AWS example above is the most commonly taught, but the underlying pattern — GitHub mints a short-lived signed JWT, the cloud provider verifies it against a pre-configured trust relationship, and hands back temporary credentials — is identical across all three major clouds. Only the name of the trust-store object and the exchange API differ:

CloudTrust-store objectExchange mechanismTypical Action
AWSIAM OIDC identity provider + IAM role trust policysts:AssumeRoleWithWebIdentityaws-actions/configure-aws-credentials
GCPWorkload Identity Federation pool + providerToken exchange against the WIF endpointgoogle-github-actions/auth
AzureFederated credential on an App Registration / Managed IdentityAzure AD token exchangeazure/login
# GCP example — conceptually identical to the AWS one, different Action and identifiers
- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/github/providers/github-actions'
    service_account: 'deploy@my-project.iam.gserviceaccount.com'
# Azure example
- uses: azure/login@v2
  with:
    client-id: ${{ vars.AZURE_CLIENT_ID }}
    tenant-id: ${{ vars.AZURE_TENANT_ID }}
    subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
    # NOTE: no client SECRET at all — the federated credential trust relationship replaces it

Every one of these still needs permissions: { id-token: write } on the job, for the same reason as the AWS example — that permission is what lets the job request an OIDC token from GitHub at all, independent of which cloud is on the receiving end. The lesson worth internalizing here is architectural, not tool-specific: OIDC federation is a general pattern (a workload proves its identity via a short-lived signed token rather than a stored secret), and once it's understood for one cloud, applying it to another is a matter of looking up that provider's specific trust-store object — the security reasoning from the AWS section applies unchanged.


Secrets Management — Repo, Environment, and Organization Levels#

GitHub secrets exist at three scopes, and understanding the precedence order matters for both security and day-to-day debugging:

ScopeVisible toTypical use
OrganizationEvery repo in the org (or a selected subset)Shared credentials many repos need (a shared container registry token)
RepositoryEvery workflow in that one repoRepo-specific secrets not tied to a particular deploy target
EnvironmentOnly jobs whose environment: matchesPer-environment credentials — a staging DB password must never leak into a job targeting production
Diagram

A job targeting the staging environment can never see PROD_DB_PASS, even though both secrets live in the same repository — this isolation is exactly why Environment-scoped secrets (introduced in Part 4) are the correct place for anything environment-specific, rather than a single repo-level secret a workflow conditionally picks between based on a branch name, which offers no real isolation at all.

Every secret value is automatically masked in workflow logs — GitHub replaces any exact-match occurrence of a secret's value with *** in log output, but this masking is naive string matching, not semantic: a secret that gets base64-encoded, split across lines, or transformed before being printed will not be masked, and has genuinely leaked secrets in real incidents. The only reliable protection is never deliberately printing a secret in the first place, masking aside.


Branch Protection Rules vs. Rulesets#

GitHub has two overlapping mechanisms for restricting what can happen to a branch, and it's worth understanding both because most existing repos still use the older one:

Branch protection rulesRulesets (newer)
ScopeOne branch pattern, per repoCan target multiple branch/tag patterns, and apply organization-wide
Bypass trackingLimitedExplicit bypass lists with a full audit trail of who bypassed and when
LayeringOne rule set per branch patternMultiple rulesets can apply to the same branch simultaneously (most restrictive wins)
Where configuredPer-repo SettingsPer-repo OR org-wide (enforced across every repo at once)

Both mechanisms configure broadly the same set of protections:

  • Require a pull request before merging — no direct pushes to the branch, full stop.
  • Require status checks to pass — this is the direct enforcement point for everything built in Part 4: a named GitHub Actions job (e.g. test) can be marked required, blocking merge until that exact job succeeds.
  • Require branches to be up to date before merging — forces a re-run against the latest main, catching integration issues a stale branch's last CI run wouldn't have seen.
  • Require signed commits — rejects any commit not cryptographically signed, defending against commit-author spoofing.
  • Restrict who can push — even with a passing PR, only specific people/teams/apps can actually perform the merge.

Organization-wide rulesets are the more powerful, more current recommendation for any org with more than a handful of repos — configuring "require passing CI + 1 review" individually on 50 repos, and remembering to apply it again on repo 51, doesn't scale; a single org-level ruleset targeting ** (all repos) enforces it everywhere at once, including on repos created in the future.


Signed Commits#

Branch protection can require signature verification — every commit on the protected branch must carry a valid cryptographic signature (GPG, SSH, or S/MIME) that GitHub can verify against a key the committer has registered to their account, shown as a "Verified" badge on the commit.

What this actually defends against, stated precisely: Git's author field (git commit --author) is trivially spoofable — anyone can git commit with user.name/user.email set to someone else's identity, and the commit will display that name in the history with no warning. A signature proves the commit was made by someone in possession of a specific private key, which is a categorically stronger claim than the plaintext author field alone.

# One-time setup — generate and register a signing key, then enable it locally
git config --global user.signingkey <key-id>
git config --global commit.gpgsign true

# Every subsequent commit is now automatically signed
git commit -m "Add deploy step"

Where this connects back to the CI/CD pipeline itself, not just human commits: a workflow that generates and commits code automatically (e.g. a GitOps-style "update the image tag" commit, as covered in Part 3) can also sign its own commits, using a bot's registered key — extending the same verifiable-provenance guarantee to automated changes, not only human ones. Requiring signed commits on main for a repo containing IaC or GitOps manifests is a meaningfully higher bar than requiring it on ordinary application code, since a spoofed commit there could alter what actually gets provisioned or deployed.

GitHub also supports SSH-based commit signing, not just GPG — a genuinely practical detail, since many engineers already have an SSH key pair for Git authentication itself and can register that same public key for signing, avoiding a second key-management workflow purely for signatures:

git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true

Whichever signing method a team standardizes on, the branch protection setting ("Require signed commits") verifies both GPG and SSH signatures identically — the choice is purely about which key-management workflow is more convenient for the team, not a security tradeoff between the two methods.


CODEOWNERS — Enforcing Review Ownership#

A CODEOWNERS file (at .github/CODEOWNERS, repo root, or docs/) maps file paths to the people or teams responsible for reviewing changes to them — and, combined with a branch protection rule requiring "Require review from Code Owners," it's what turns "please get the right person to review this" from a social convention into an enforced, unbypassable gate.

# .github/CODEOWNERS
# Later patterns take precedence over earlier ones for the same file

*                           @platform-team          # default owner for anything not matched below
/frontend/                 @frontend-team
/backend/                  @backend-team
/.github/workflows/        @platform-team @security-team   # pipeline changes need BOTH teams
*.tf                        @infra-team              # Terraform files specifically, regardless of directory

When a PR touches /.github/workflows/deploy.yml, GitHub automatically requests review from both @platform-team and @security-team, and — if the branch protection rule is configured to require it — the PR cannot be merged until at least one member of each matched team has approved, no matter how many other people approved it. This is the concrete enforcement mechanism behind the layered-defense idea from the earlier GITHUB_TOKEN security search results: "required reviews + mandatory CI + CODEOWNERS" stacked together closes gaps any single one of them leaves open on its own.


Dependabot — Automated Dependency Updates#

Dependabot runs two genuinely distinct capabilities, both configured in the same .github/dependabot.yml file, and conflating them is a common source of confusion:

Version updatesSecurity updates
TriggerScheduled (e.g. weekly), proactiveReactive — a known CVE is found in a currently-used dependency version
What it doesOpens a PR bumping to the latest version, on a scheduleOpens a PR bumping only past the vulnerable version, as soon as an advisory is published
ConfigurationExplicit .github/dependabot.yml requiredEnabled by default on public repos; a toggle on private repos, no file needed
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
  - package-ecosystem: "github-actions"     # keeps pinned Action SHAs current (see earlier section)
    directory: "/"
    schedule:
      interval: "weekly"
  - package-ecosystem: "docker"
    directory: "/"
    schedule:
      interval: "weekly"

Every Dependabot PR runs through the exact same CI pipeline and branch protection rules as a human-authored PR — required status checks still block merge, required reviewers still apply — meaning a dependency bump is only ever merged if it passes the same tests everything else does, directly reusing the "same pipeline for every change, no special-cased path" principle from Part 1.


GitHub Advanced Security — CodeQL and Secret Scanning#

GitHub Advanced Security (GHAS) bundles GitHub's native security-scanning tools — free on public repos, a paid add-on for private repos on most plans:

FeatureWhat it findsWhen it runs
CodeQL (code scanning)SAST — actual vulnerability patterns in source code (SQL injection, XSS, unsafe deserialization)On every PR (via a workflow) or scheduled
Secret scanningCredentials accidentally committed (API keys, tokens, private keys) — checked against known provider token formatsContinuously, on every push
Secret scanning push protectionThe same detection, but blocks the push itself before the secret ever lands in historyAt push time, before the commit is even accepted
Dependency reviewFlags a PR that introduces a dependency with a known vulnerability, inline in the PR diffOn every PR that touches a manifest file

CodeQL runs as a workflow, using the same job/step mechanics from Part 4 — it's genuinely just another GitHub Actions job, which is why it composes naturally with everything else in this chapter:

name: CodeQL
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 3 * * 1'          # weekly deep scan, catches newly-published query patterns
jobs:
  analyze:
    runs-on: ubuntu-latest
    permissions:
      security-events: write     # required to upload findings back to the Security tab
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: github/codeql-action/init@v3
        with:
          languages: javascript
      - uses: github/codeql-action/analyze@v3

Secret scanning push protection is worth calling out as the single highest-leverage GHAS feature to enable first, because it addresses the problem at its cheapest possible point — the same "fail fast, catch it as early as possible" principle from Part 1's pipeline-stage ordering, applied to the earliest possible moment of all: before a secret is even accepted into Git history. Once a secret is committed and pushed, removing it from history is a genuinely painful git filter-repo/force-push operation across every clone — prevention at push time avoids that entirely.


Artifact Attestations — Build Provenance and SLSA#

Everything covered so far protects the pipeline. This section protects the output of the pipeline — answering the question "given this binary/container image sitting in a registry, can I prove it was actually built by our real CI pipeline, from our real source code, and hasn't been tampered with since?" This is GitHub's implementation of provenance concepts from the SLSA framework (Supply-chain Levels for Software Artifacts), an industry standard for describing how trustworthy a build's supply chain actually is.

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      id-token: write        # needed to sign the attestation, same underlying OIDC mechanism as cloud auth
      contents: read
      attestations: write    # needed to publish the attestation
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t my-app:${{ github.sha }} .
      - uses: actions/attest-build-provenance@v1
        with:
          subject-name: 'my-app'
          subject-digest: 'sha256:${{ steps.build.outputs.digest }}'

This generates a signed attestation — a cryptographically verifiable statement binding the exact artifact digest to the exact workflow run, commit, and repository that produced it — published to the repo's Attestations tab. Anyone (a deploy pipeline, a security team, an auditor) can later verify it independently:

gh attestation verify my-app.tar --owner my-org
Diagram

Why this matters even in a fully OIDC-hardened, SHA-pinned pipeline: everything else in this chapter protects the build process. Attestations protect the chain of custody afterward — a container image can sit in a registry for weeks before being deployed, during which time an attacker with registry write access could swap it for a malicious image with the same tag. A deploy pipeline that verifies the attestation before pulling refuses to deploy anything that doesn't carry valid, matching provenance — closing the gap between "we built it securely" and "we're certain what we're about to deploy is actually what we built."


Scanning and Signing Container Images in the Pipeline#

Attestations (above) prove who built an artifact. Two related but distinct practices address a different question: is the artifact itself safe, and can a consumer verify it hasn't been swapped at the registry level — both of which reuse SAST/SCA concepts already established in this course's DevSecOps series, applied specifically to the container images a GitHub Actions pipeline commonly builds and pushes.

Image vulnerability scanning — run a scanner (Trivy, Grype, or GitHub's own Dependabot for container base images) against the built image before pushing it anywhere, exactly matching the "cheap checks before expensive ones" ordering from Part 1:

jobs:
  build-and-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: docker build -t my-app:${{ github.sha }} .
      - name: Scan image for vulnerabilities
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: 'my-app:${{ github.sha }}'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'          # fail the job if anything CRITICAL/HIGH is found
      - name: Push (only reached if the scan passed)
        run: docker push my-app:${{ github.sha }}

Image signing — cryptographically sign the pushed image itself (distinct from the build-provenance attestation, though they're commonly used together), most commonly via Sigstore's cosign, using the same OIDC identity mechanism already covered in this chapter rather than a manually-managed signing key:

      - name: Sign the image (keyless, via OIDC identity)
        run: cosign sign --yes my-app@${{ steps.push.outputs.digest }}
        env:
          COSIGN_EXPERIMENTAL: "1"   # keyless signing using GitHub's own OIDC token as identity

A deploy pipeline (in Kubernetes, this is commonly enforced by an admission controller like Sigstore's policy-controller or Kyverno) then refuses to run any image that isn't signed by a trusted identity — closing the same "was this actually deployed thing what we think it is" gap as attestations, but enforced at the cluster's admission boundary rather than only at the CI pipeline's own deploy step, giving a second, independent enforcement point even if the pipeline itself were somehow bypassed.

Diagram

Spending Limits and Abuse Prevention#

A security incident in a CI/CD system doesn't always mean stolen data — a compromised workflow, or even an innocent infinite-loop bug in a workflow_dispatch-triggered self-replicating workflow, can also manifest purely as an unexpectedly enormous Actions bill. This is a governance concern that belongs in this chapter specifically because the mitigations are largely the same access controls already covered, applied with a cost lens:

  • Spending limits (org billing settings) cap total Actions minutes spend per billing cycle — a hard stop that turns "we got a $40,000 surprise bill" into "Actions stopped running once the configured cap was hit," which is a far better failure mode.
  • concurrency: limits (introduced conceptually in Part 4) also function as a cost control, not just a correctness one — capping how many redundant runs of the same workflow can execute simultaneously.
  • Required approval for workflows from forks (covered under self-hosted runner security) doubles as cost protection on public repos even when GitHub-hosted runners are used — without it, a malicious actor could open hundreds of PRs each triggering an expensive matrix build, running up minutes even on infrastructure GitHub itself hosts.
  • Scheduled workflows (schedule:) that a team forgets exist are a surprisingly common, non-malicious source of slow cost creep — a nightly job left running long after the project it served was decommissioned. A periodic audit of schedule:-triggered workflows across an organization, matched against which repos are still actually active, is cheap governance hygiene worth doing on a recurring basis.

Organization-Level Security Controls#

Everything so far in this chapter operates at the repository or workflow level. A handful of controls only exist at the organization level, configured once and enforced everywhere beneath it — genuinely important for an SRE/platform team responsible for an entire org's security posture, not just one repo's:

ControlWhat it enforcesWhy it matters
Mandatory two-factor authenticationEvery member must have 2FA enabled to remain in the orgCloses the single most common account-takeover vector — a leaked or reused password alone is no longer sufficient
SAML/SSO enforcementMembers must authenticate through the org's identity providerCentralizes offboarding — disabling someone in the IdP immediately cuts their GitHub access too, no separate manual step to forget
IP allow listsGit and API access restricted to specific IP ranges (e.g. a corporate VPN or office network)Even a valid, correctly-scoped credential is useless from outside the allowed network
Audit log (+ streaming)Every security-relevant action (permission change, secret access, SSO config change) recorded, optionally streamed to an external SIEMForensic capability after an incident, and ongoing anomaly detection feeding a real security pipeline
Org-wide required workflows / rulesetsThe rulesets from earlier in this chapter, but applied org-wide instead of per-repoGuarantees a baseline (required CI, required review) on every repo, including ones created tomorrow

The audit log deserves particular emphasis for an SRE/platform audience specifically, because it's the record that answers "who changed the required-status-checks list on main last Tuesday, and did they have the authority to?" — the same kind of after-the-fact accountability question this course's Incident Management series covers for production changes generally, applied here to changes in the pipeline's own governance configuration.


Compliance Frameworks and GitHub — What Auditors Actually Ask For#

For a platform or SRE team operating under a compliance framework (SOC 2, ISO 27001, or an internal audit program), the controls in this chapter aren't just good security practice — they're frequently the literal evidence an auditor asks for. Mapping the two together is a genuinely practical exercise, since it turns an abstract compliance requirement into a concrete GitHub setting to point at:

Common audit requirementGitHub control that satisfies it
"Changes require independent review before production"Branch protection/rulesets requiring PR review + passing status checks
"Access is provisioned and revoked promptly on role change"SAML/SSO enforcement — offboarding in the IdP immediately revokes GitHub access
"Privileged actions are logged and attributable to an individual"Organization audit log (streamed to a SIEM for long-term retention)
"Production credentials are not stored in plaintext or long-lived"OIDC — no static cloud credential exists to be "stored" at all
"Software is built from a known, verifiable source"Build provenance attestations, verified before deploy
"Multi-factor authentication is enforced for privileged access"Organization-mandatory 2FA
"Code changes are scanned for known vulnerabilities before release"CodeQL + dependency review + image vulnerability scanning, all as required checks
"Deployment credentials are rotated regularly / are not shared across environments"OIDC issues fresh, scoped credentials per run — "rotation" happens automatically, every single time
"There is a documented incident response process for security events"The runbook from the Incident Response section, extended with the org's own escalation contacts

The practical implication for an SRE/platform team preparing for an audit: most of the actual "evidence gathering" burden disappears if these controls were enabled from the start rather than retrofitted right before an audit — the org audit log, once enabled, has already been accumulating exactly the record an auditor wants to see, going back to whenever it was turned on. Retrofitting compliance right before an audit deadline means there's no historical evidence covering the period before the controls existed, which is itself often a finding. This is the same argument this course's DevSecOps series already made for shifting security left generally — shifting compliance left specifically means enabling org-wide 2FA, SSO, and audit logging on day one of a new organization, not the week before a SOC 2 Type II observation period begins.


Self-Hosted Runner Security in Depth#

Part 4 flagged self-hosted runners on public repos as risky; this section covers exactly why and how to mitigate it.

The core threat model: a self-hosted runner is a machine your organization controls, sitting inside (or with access to) your real network. Any workflow that runs on it executes with whatever access that machine has. On a public repository, anyone can open a pull request — and if a workflow is configured to run on pull_request events using a self-hosted runner, an untrusted external contributor's code can execute directly on your infrastructure.

Concrete hardening measures, roughly in order of how much they reduce risk:

  1. Never attach a self-hosted runner directly to a public repository's default pull_request trigger. Use GitHub-hosted runners for anything triggered by external, un-reviewed PRs.
  2. Require approval for first-time contributors' workflow runs (a repo setting) — a maintainer must manually approve a first-time contributor's workflow before it executes at all, giving a human a chance to read the diff first.
  3. Use ephemeral, single-job runners — a runner that executes exactly one job and then is destroyed (common with the Actions Runner Controller on Kubernetes) rather than a long-lived, reused machine — so a compromised job can't leave persistent malware for the next job to inherit.
  4. Never store long-lived secrets directly on the runner's filesystem or environment — pull anything sensitive fresh at job start (ideally via OIDC, per this chapter), so a compromised runner's blast radius is limited to that one run's short-lived credentials.
  5. Network-isolate the runner to only the specific internal resources it genuinely needs — a runner that can reach the entire internal network turns any workflow-level compromise into a full internal-network foothold.

The pull_request_target Trap#

This deserves its own section because it is the single most common real-world GitHub Actions security incident pattern, and the mistake is genuinely easy to make without realizing it.

pull_request and pull_request_target look nearly identical but behave very differently:

pull_requestpull_request_target
Runs with whose secrets/token?The fork's limited, read-only contextThe base repo's full secrets and write-capable token
Checks out which code by default?The PR's (potentially untrusted) branchThe base branch — unless a step explicitly checks out the PR's head instead
Why it existsSafe default for running CI against untrusted PR codeNeeded for workflows that must comment/label PRs from forks, which pull_request can't do (forks don't get write tokens)

The trap: a workflow author needs pull_request_target for its write access (to post a PR comment, say), but then — often to actually test something about the PR — adds a step that checks out and executes the PR's own head branch code. That single combination means an external contributor's pull request can get its own arbitrary code executed with the base repository's real secrets and write-capable token — the exact opposite of the isolation pull_request_target was supposed to preserve.

Diagram

The rule to follow, without exception: if a workflow needs pull_request_target's elevated permissions, it must never check out and execute the PR's own head commit. If a workflow genuinely needs to build/test the PR's actual code, use plain pull_request instead (accepting the more limited, read-only token) — the two requirements (elevated write access, and executing untrusted fork code) are fundamentally incompatible in the same job.


Responding to a Compromised Pipeline or Leaked Secret#

Every hardening measure in this chapter reduces the probability of a compromise — none of them reduce it to zero, and this course's Incident Management series has already established that a real SRE practice plans for the "it happened anyway" case, not just prevention. A short, concrete runbook for the two most common pipeline-specific incident types:

A secret was leaked (committed to history, printed in a log, or exfiltrated by a compromised dependency):

  1. Rotate the credential immediately, at the source — not just delete the GitHub secret. A leaked AWS key is exploitable by anyone who copied it until AWS itself is told to invalidate it; deleting the GitHub Actions secret only stops this pipeline from using it, doing nothing to stop an attacker who already has a copy.
  2. Assume the secret was used — check the credential's own access logs (CloudTrail for AWS, Cloud Audit Logs for GCP, Activity Log for Azure) for the exposure window, not just GitHub's audit log, since the actual damage happens on the credential's home platform.
  3. If committed to Git history, rotating the credential is the real fix — a git filter-repo history rewrite to scrub the value is good hygiene afterward, but does nothing on its own, since anyone who already cloned or forked the repo still has the old history with the secret in it.
  4. This is exactly the argument for OIDC over long-lived credentials from earlier in this chapter, made concrete: an OIDC-derived credential leaked in a log is worthless within roughly an hour regardless of any response action at all, because it self-expires — the entire "rotate immediately" scramble above simply doesn't apply to a credential type that was never long-lived in the first place.

A workflow or Action is suspected of being compromised (an unexpected code change in a pinned dependency, unusual outbound network activity from a runner, an unfamiliar workflow run):

  1. Disable the workflow immediately (repo Settings → Actions, or gh workflow disable) to stop further runs while investigating.
  2. Review the audit log for the exact window — what permissions did the suspicious run actually have, what API calls did it make, did it touch any secrets.
  3. Rotate every secret that specific workflow (or job) had access to — per the least-privilege scoping from earlier in this chapter, this blast-radius question is answerable precisely because permissions were scoped per-job rather than left at a broad default; a workflow that never should have had write access to production secrets in the first place bounds how bad this step needs to be.
  4. If a third-party Action is the suspected vector, compare the currently-pinned SHA against the Action's own repository history for anything unexpected, and consider removing the dependency entirely pending the maintainer's own incident response.
Diagram

A Worked Example: Verifying Everything Together at Deploy Time#

Tying the attestation, signing, and OIDC sections together into one realistic deploy job — the point where all of this chapter's provenance and identity work actually pays off, by refusing to deploy anything that can't prove it's legitimate:

jobs:
  deploy-production:
    runs-on: ubuntu-latest
    environment: production          # Part 4's approval gate — a human already signed off
    permissions:
      id-token: write                # for the OIDC exchange to the cloud provider
      attestations: read             # for verifying build provenance
      contents: read
    steps:
      - name: Verify build provenance before touching anything
        run: |
          gh attestation verify oci://my-registry/my-app:${{ github.sha }} \
            --owner my-org --repo my-org/my-app
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Verify image signature
        run: cosign verify my-registry/my-app:${{ github.sha }} \
            --certificate-identity-regexp "https://github.com/my-org/my-app/.github/workflows/" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com"

      - name: Authenticate to cloud provider via OIDC — no static credential
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1

      - name: Deploy — only reached if every prior verification step passed
        run: ./deploy.sh --image my-registry/my-app:${{ github.sha }}

Read this job top to bottom as a chain of increasingly specific trust questions, each one a hard gate — if any step fails, the job stops and nothing downstream runs at all:

  1. Was this image actually built by our real pipeline? (attestation)
  2. Is this exact image, right now, signed by an identity we trust — not swapped since it was built? (cosign signature)
  3. Are we, the deploying job, who we claim to be, to the cloud provider? (OIDC)
  4. Only then — deploy.

This is the concrete, executable realization of the layered defense-in-depth diagram from earlier in this chapter — not an abstract principle, but five actual lines of YAML a real deploy job can run today.


Quick Reference: Security Checklist#

A condensed checklist form of this entire chapter, useful as an actual audit tool against a real repository:

ControlCovered in
permissions: set explicitly at workflow level, minimal by defaultGITHUB_TOKEN section
Every third-party Action pinned to a full commit SHAPinning section
Dependabot configured for github-actions, plus every real package ecosystem in usePinning + Dependabot sections
No long-lived cloud credentials stored as secrets — OIDC used insteadOIDC sections
Secrets scoped to the narrowest level that works (Environment > Repo > Org)Secrets Management section
Branch protection or org-wide ruleset requiring passing CI + review on mainBranch Protection section
CODEOWNERS covering .github/workflows/ at minimumCODEOWNERS section
Secret scanning push protection enabledGHAS section
CodeQL (or an equivalent SAST) running on every PRGHAS section
Build provenance attestations published for any artifact that gets deployedAttestations section
Org-wide 2FA enforcement enabledOrg-Level Controls section
No self-hosted runner wired to a public repo's default pull_request triggerSelf-Hosted Runner Security section
Every pull_request_target workflow audited for the head-checkout trappull_request_target section
A written, rehearsed runbook for a leaked secret or compromised workflowIncident Response section
Container images scanned for vulnerabilities before push, signed afterImage Scanning/Signing section
Org-level Actions spending limit configuredSpending Limits section
SSO enforcement paired with mandatory 2FA, not treated as a substitute for itOrg-Level Controls section
Audit log streaming to a SIEM enabled from day one, not retrofitted pre-auditCompliance section

Tying It Together — Defense in Depth Across the Pipeline#

Every control in this chapter has been introduced individually; it's worth stepping back and seeing them as one coherent, layered system, because that's genuinely how they're meant to function — no single control here is sufficient on its own, and that's by design, not a gap. This directly reuses the "layered scanning pipeline" defense-in-depth idea from this course's DevSecOps series, mapped onto every stage a change moves through on GitHub specifically.

Diagram

Why layering matters in practice, with a concrete failure-chain example: imagine a malicious dependency somehow makes it past code review (Layer 2 failed, or was bypassed by a legitimate-looking but compromised PR). Least-privilege GITHUB_TOKEN scoping (Layer 3) means that dependency's malicious postinstall script — even if it executes during npm ci — can't push to main, can't read production secrets, and can't touch the deploy environment, because the job it ran in was never granted those scopes in the first place. If somehow a bad artifact still got built, the vulnerability scan (Layer 4) has a chance to catch it before push. If it still got pushed, the deploy pipeline's attestation/signature verification (Layer 5) refuses to deploy anything not provably built by the real pipeline. And if all of that somehow failed, the audit log (Layer 6) is what makes the resulting incident investigable at all, rather than a mystery.

No individual layer needs to be perfect for the system as a whole to hold — this is the actual argument for building all of them rather than picking "the one best control." A team with limited time should prioritize roughly in the order this chapter presented them: GITHUB_TOKEN least privilege and SHA-pinning are the cheapest to implement and close the most common real-world attack pattern first; OIDC and branch protection are the next tier; attestations, image signing, and org-wide 2FA/SSO round out a genuinely mature posture.

This layered model is also the right way to reason about a genuinely new, not-yet-covered risk showing up in the future. GitHub Actions itself is an actively evolving product — new attack patterns get discovered against any sufficiently popular CI/CD platform, and new mitigating features ship in response (attestations and rulesets are both, relatively speaking, newer additions to the platform than GITHUB_TOKEN scoping). The specific list of controls in this chapter will keep growing; the underlying discipline — assume any single layer can fail, and make sure the next one still holds — does not change, and is the actual skill worth carrying forward past this chapter's specific checklist.


Common Mistakes#

MistakeWhy it's a problemFix
Leaving GITHUB_TOKEN at its broad defaultAny compromised dependency in a job inherits far more access than the job needsSet permissions: { contents: read } at workflow level; escalate per-job, minimally
Referencing third-party Actions by a mutable tag (@v4)The tag owner (or an attacker who compromises them) can change what code runs, silentlyPin to a full commit SHA; use Dependabot to keep it current via reviewable PRs
Storing a long-lived cloud access key as a secretA leaked key is valid indefinitely until manually rotatedUse OIDC — short-lived, auto-expiring, no static secret to leak in the first place
Forgetting permissions: { id-token: write } on an OIDC jobThe job can't request an OIDC token, fails with a confusing auth errorAdd it explicitly — it's not implied by any other permission
Treating secret log-masking as a full guaranteeMasking is naive string matching — an encoded/transformed secret leaks straight throughNever deliberately print a secret; don't rely on masking as the primary control
pull_request_target + checking out and running the PR's own head codeUntrusted fork code executes with the base repo's real secrets and write tokenNever combine the two; use plain pull_request if the workflow must execute the PR's own code
Self-hosted runner wired to a public repo's default PR triggerAny external contributor can execute code on your infrastructure via a PRGitHub-hosted runners for public-repo PR triggers, or heavily hardened ephemeral self-hosted runners
No CODEOWNERS + required-review enforcement on .github/workflows/Anyone with merge rights can silently change what the pipeline does, including its permissionsRequire CODEOWNERS review specifically on the workflows directory
Rotating a leaked GitHub Actions secret without rotating it at the source (e.g. AWS)The credential is still valid and exploitable on the cloud provider's side regardless of the GitHub secret being deletedRotate at the source system first; deleting the GitHub secret alone stops nothing an attacker already has a copy of
Pushing an unscanned container image straight to a production registryA CRITICAL vulnerability in a base image or dependency ships unnoticedRun a vulnerability scan (Trivy/Grype) as a required, blocking step before the push step
No spending limit configured on Actions billingA runaway or compromised workflow can generate an unbounded bill before anyone noticesSet an org-level spending limit as a hard backstop, independent of any monitoring/alerting
Using a classic, broadly-scoped Personal Access Token for org automationTied to one person's account; breaks (or worse, silently keeps working with excess access) after they leaveUse a GitHub App installation token for anything automated and org-owned
Enabling SSO but skipping org-mandatory 2FA, assuming SSO alone covers itSSO handles identity federation; it doesn't stop a phished or reused IdP credential from being used by an attackerEnable both — they defend against different threats and aren't substitutes for each other
Retrofitting audit logging right before a compliance audit deadlineNo historical evidence exists for the period before logging was enabled — itself often a findingEnable org audit log streaming on day one of a new organization, not reactively
Treating "required review" as satisfied by any approver, with no CODEOWNERSA reviewer with no actual context or authority over the changed area can rubber-stamp approvePair required review with CODEOWNERS so the right person, not just a person, must approve
Assuming a signed image can't be swapped because it was scanned once at build timeA scan result is a point-in-time snapshot of the build, not a guarantee about the registry afterwardVerify the signature/attestation again at deploy time, not only at build time — the worked example above does exactly this

Worked Practice Problems#

Problem 1: A security review finds a workflow with permissions: write-all at the top of the file, used for a job that only checks out code and runs npm test. What's wrong, and what's the fix?

Answer: write-all grants every possible scope (contents, issues, PRs, packages, deployments, and more) to a job that provably needs only contents: read to clone the repo and run tests. This violates least privilege badly — if any dependency pulled in by npm ci is compromised (a real, recurring supply-chain attack pattern), the malicious code runs with write access to essentially everything the token could touch, rather than being contained to read-only repo access. Fix: replace with permissions: { contents: read } at the workflow level, and only escalate specific scopes in specific jobs that provably need them (e.g. a separate job that comments on PRs gets pull-requests: write, nothing else).

Problem 2: A team wants Dependabot to keep their pinned third-party GitHub Actions current without giving up SHA-pinning's security guarantee. Are these two goals actually compatible, and how?

Answer: Yes — they're not in tension at all, because Dependabot doesn't bypass review; it automates the proposal, not the acceptance. Configuring package-ecosystem: "github-actions" in dependabot.yml makes Dependabot open a PR whenever a pinned Action has a newer release, updating the SHA (with the human-readable version in a trailing comment) — but that PR still goes through the exact same required status checks and required reviews as any other change, per the branch protection rules covering .github/workflows/. The team gets the low-toil convenience of not manually tracking updates while keeping the actual security property (nothing runs without being reviewed first) fully intact.

Problem 3: A workflow needs to post an automated comment on pull requests from external forks (e.g. "thanks for your contribution, CI results: ..."), which requires a write-capable token that pull_request alone doesn't provide to fork-originated runs. How do you implement this without falling into the pull_request_target trap?

Answer: Use pull_request_target for the write access it grants, but the job must never check out or execute the PR's own head branch code — it should only read data about the PR (title, number, author) from the github.event context, and if it needs the results of tests that already ran against the untrusted code, use the two-workflow pattern: a pull_request-triggered workflow (limited token, safely runs the untrusted code, uploads results as an artifact) plus a separate workflow_run-triggered workflow (elevated token via pull_request_target-equivalent trust, but only ever downloads and reads the artifact — never executes fork code directly) that posts the comment. This keeps "run untrusted code" and "hold a write-capable token" permanently in two separate jobs that never overlap.

Problem 4: A platform team wants to guarantee that only artifacts actually built by their real CI pipeline ever get deployed to production — closing the gap where someone with registry push access could swap a container image after the fact. Which mechanism from this chapter addresses this specific gap, and why doesn't SHA-pinning or OIDC alone solve it?

Answer: Build provenance attestations (actions/attest-build-provenance), verified with gh attestation verify as a required step in the deploy pipeline before pulling any image. SHA-pinning protects the inputs to a build (which Actions ran), and OIDC protects how the pipeline authenticates outward — neither says anything about whether the specific artifact sitting in the registry right now is actually the one that pipeline produced. Attestations close exactly that gap: a signed, independently-verifiable statement binding an artifact's digest to the exact workflow run that built it, checked at deploy time rather than trusted implicitly.

Problem 5: During a routine audit, a platform engineer finds a workflow using pull_request_target, checking out github.event.pull_request.head.sha (the fork's own commit), and running npm test from that checkout — with the job also having contents: write and a production deploy secret available. Walk through exactly what's exploitable here and the minimal fix.

Answer: This is the pull_request_target trap in its most direct form: npm test executes arbitrary code from the untrusted fork's package.json (via postinstall/pretest scripts, or npm test itself if the fork controls the test files) — and that code runs inside a job holding contents: write and a production deploy secret, meaning any external contributor who opens a malicious pull request can have their code run with production-level access on the very first PR, no merge required. The minimal fix: split into two workflows — a pull_request-triggered one (limited, fork-scoped token) that safely runs npm test against the untrusted code and uploads only the pass/fail result as an artifact, and a separate workflow using pull_request_target (or workflow_run) that never checks out the fork's code at all, only reads the artifact's result and the safe, structured github.event metadata to decide whether to comment on the PR. The two capabilities — executing untrusted code, and holding elevated credentials — must never share a job.

Problem 6: An org's monthly GitHub Actions bill triples with no corresponding increase in engineering headcount or shipped features. Walk through the diagnostic steps a platform engineer should take, and name at least two structural controls that would have caught this sooner.

Answer: Diagnostic steps: first, check the Actions usage report (Settings → Billing) broken down by repository and workflow to find which specific workflow's minutes spiked — this is almost always a single runaway workflow, not a broad, even increase across the org. Common root causes to check for: a schedule:-triggered workflow left running after the project it served was decommissioned (the "forgotten cron" pattern from the Abuse Prevention section), a matrix build that grew (someone added a new OS or version dimension without noticing the multiplicative cost, especially if macOS — billed at 10× — was added), a concurrency: group missing so every push queues a full parallel run instead of superseding the prior one, or genuinely malicious activity (a compromised token spinning up expensive self-hosted-adjacent workflows, or fork PRs abusing an under-protected public repo). Structural controls that would have caught this earlier: an org-level spending limit as a hard backstop regardless of cause, and a periodic audit of scheduled workflows cross-referenced against which repos are still active — both proactive, catching the problem before a bill arrives rather than after.

Problem 7: An auditor preparing a SOC 2 report asks a platform team to demonstrate that "all changes to production infrastructure require independent review and are traceable to an individual." The team's Terraform repo (from Part 2's IaC conventions) has branch protection requiring one approval, but no CODEOWNERS file, and the org has SSO enforced but 2FA enforcement is off (SSO alone was assumed to be sufficient). Identify every gap against the stated requirement and the fix for each.

Answer: Three distinct gaps. First, "independent review" without CODEOWNERS means any org member with write access can approve any change, including someone approving their own colleague's change in a domain they have no actual expertise or authority over — a technically-satisfied checkbox that doesn't reflect genuine independent review; the fix is a CODEOWNERS file mapping the Terraform directories to the specific infra team responsible for them, combined with "Require review from Code Owners" in the branch protection rule. Second, "traceable to an individual" depends on the audit log correctly attributing every action to a real person — SSO alone handles authentication, but without mandatory 2FA, a compromised password (independent of SSO, e.g. a phished IdP credential) could let an attacker act as that person, undermining the traceability claim at its root; the fix is enabling org-mandatory 2FA even with SSO already enforced, since the two controls address different threats (identity federation vs. credential compromise) and are not substitutes for each other. Third, the requirement says "all changes" — worth explicitly verifying the branch protection rule has no bypass list exempting admins or a specific team, since an unreviewed bypass path would mean the control doesn't actually cover "all" changes as claimed, regardless of how well-configured the rest of it is.


Summary and What's Next#

A GitHub Actions pipeline earns trust with real production secrets only after this hardening pass: GITHUB_TOKEN scoped to least privilege per-job, third-party Actions pinned to a commit SHA (kept current via Dependabot's reviewable PRs, not blind auto-updates), OIDC replacing long-lived cloud credentials entirely, secrets correctly scoped across organization/repo/environment boundaries, branch protection or org-wide rulesets enforcing required checks and reviews, CODEOWNERS closing the "wrong person approved this" gap, Dependabot and GitHub Advanced Security (CodeQL, secret scanning, push protection) catching what the pipeline itself can't, image scanning and signing protecting the build's actual output, build provenance attestations closing the registry-swap gap, org-wide 2FA/SSO/audit logging closing the account-level gaps no per-repo control can reach, and a hard rule around pull_request_target never executing untrusted fork code. None of these is sufficient alone — that's the entire point of the layered defense-in-depth model this chapter closed with; together, Parts 4 and 5 form a complete, production-grade picture of building and securing a pipeline on GitHub specifically, from a first working workflow all the way to something a compliance auditor would sign off on.

Part 6 moves to GitLab — a platform with a meaningfully different philosophy (an integrated DevOps platform built around a single .gitlab-ci.yml, DAG-based pipelines via needs:, and CI/CD Components as its reusability primitive) — comparing and contrasting against everything just covered for GitHub.

As with Part 4, treat this chapter's checklist as a living document rather than a one-time pass: revisit it whenever GitHub ships a genuinely new security primitive, whenever an incident (this org's own, or a widely-reported industry one) reveals a gap the current checklist doesn't cover, and at minimum on a regular audit cadence independent of either trigger.

The next two chapters keep asking the same underlying questions — how does the platform enforce review, how are secrets scoped, how does deploy authentication avoid long-lived credentials — against a different set of platform-native answers, and Part 8's closing comparison table lines all four providers up side by side against these exact questions.

Keep the Quick Reference checklist from this chapter close at hand while reading them — it holds up as a review lens for any provider, not only GitHub, since the underlying security concerns it encodes are platform-agnostic even where the specific setting name differs.