BYOWBuild Your Own Workflow

Part 2a · GitHub Actions

A secure pipeline on GitHub Actions

Harden the organization, lock down every job, then build a CI workflow with CodeQL, dependency review and IaC scanning, and a release workflow that scans, attests and deploys a verified image digest to Azure with OIDC.

~40 min actions/checkout v7.0.1 codeql-action v4.38.3 actions/attest v4.2.2 All actions SHA-pinned

What you will build

The example is a Node.js service, contoso/payments-api, packaged as a container image in GitHub Container Registry and deployed to Azure Container Apps. Swap the build and deploy commands for your stack; the security structure stays the same.

payments-api/
├── .github/
│   ├── workflows/
│   │   ├── ci.yml          # pull requests + main: test, SAST, SCA, IaC  → merge gate
│   │   └── release.yml     # main only: build, scan, SBOM, attest, deploy → release gate
│   ├── dependabot.yml      # keeps SHA pins and packages current
│   └── CODEOWNERS          # platform-security owns pipeline files
├── Dockerfile
└── src/

Two workflows, not one, is deliberate. ci.yml runs untrusted pull request code, so it gets a read-only token and no secrets. release.yml only runs after merge to main, so it can hold write access to the registry and the production identity.

Step 1Harden the organization and repository

These settings change once and protect every workflow. Do them before writing YAML, because a well-written workflow in a permissive organization is still exposed.

Allow only actions you trust Org settings›Actions›General›Policies Allow actions created by GitHub, optionally Marketplace actions from verified creators, and an explicit list of others. Prefix an entry with ! to block a specific action or version outright when one is reported compromised.
Require actions to be pinned to a full-length commit SHA Org settings›Actions›General›Policies Any workflow that references an action by tag or branch fails. The check also applies to actions called inside composite actions, so some third-party actions will need replacing. Turn it on per repository first if you have many workflows.
Default workflow permissions: read-only Settings›Actions›General›Workflow permissions Choose "Read repository contents and packages permissions" and clear "Allow GitHub Actions to create and approve pull requests". Jobs that need more ask for it in YAML.
Require approval for fork pull request workflows Settings›Actions›General›Fork pull request workflows Pick the strictest approval option for outside contributors. In private repositories, leave "Send write tokens" and "Send secrets" to fork workflows switched off.
Ruleset on the default branch Settings›Rules›Rulesets Block force pushes and deletion; require a pull request with at least one approval, Code Owner review, dismissal of stale approvals and approval of the most recent push; require the ci status checks; require code scanning results with no high-severity alerts.
Secret Protection with push protection, Code Security Settings›Advanced Security Turn on the dependency graph, Dependabot alerts, secret scanning with push protection, and code scanning. Public repositories get these at no cost; private repositories need GitHub Secret Protection and GitHub Code Security licenses.
A production environment with reviewers Settings›Environments›production Add required reviewers and enable "Prevent self-review". Limit deployment branches to main. Store the Azure identifiers here as environment variables, so only jobs that target this environment can see them.

Then give pipeline files an owner, so no workflow change merges without a platform-security review:

.github/CODEOWNERStext
# Pipeline definitions and their exceptions need a platform-security review
/.github/workflows/      @contoso/platform-security
/.github/dependabot.yml  @contoso/platform-security
/.github/CODEOWNERS      @contoso/platform-security
/.trivyignore.yaml       @contoso/platform-security

Step 2Lock down every job

Every job in this tutorial follows the same skeleton. Learn it once and the full workflows read easily.

job skeletonyaml
permissions: {}                  # workflow level: no token scopes at all

jobs:
  example:
    runs-on: ubuntu-24.04        # a fixed image, not ubuntu-latest, so upgrades are deliberate
    timeout-minutes: 15          # a hung or hijacked job cannot run for 6 hours
    permissions:
      contents: read             # opt in to exactly what this job needs
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false   # don't leave the token in .git/config for later steps
  • Empty permissions at the top, grants per job. A job with permissions: {} gets a token that can do nothing. Each job then lists only the scopes it uses, such as security-events: write to upload scan results or id-token: write to request an OIDC token.
  • Full commit SHA plus a version comment. A SHA cannot be moved the way a tag can, which is what made the 2025 and 2026 tag-hijacking incidents possible. The trailing # v7.0.1 comment is how Dependabot and reviewers know what the SHA is.
  • persist-credentials: false. By default checkout writes the token into the repository's git config, where any later step, including a compromised dependency, can read it.

Optional: watch network egress

To see which hosts each job contacts, add step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 as the first step with egress-policy: audit. Once you know the normal endpoints, switch to block with an allowlist so a stealer cannot call home.

Step 3The CI workflow: your merge gate

ci.yml runs on every pull request, on pushes to main, and weekly so new CVEs surface on code that has not changed. It has four jobs that run in parallel. Mark all four as required status checks in the ruleset; that is what turns them into a gate.

JobGate typeToken scopesFails when
testQualitycontents: readTests fail
codeqlSASTcontents: read, security-events: writeRuleset sees a new high alert
dependenciesSCA + licensecontents: readNew dependency with a high CVE or a license not on the list
iacIaCcontents: read, security-events: writeCritical or high misconfiguration

SAST with CodeQL, including your workflows

The matrix runs CodeQL twice: once for the application language and once for actions, which analyzes the workflow files themselves for script injection and risky triggers. build-mode: none works for interpreted languages and for Java and C#; compiled languages that need a build use autobuild or manual.

.github/workflows/ci.yml · codeql jobyaml
  codeql:
    name: SAST (CodeQL)
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    permissions:
      contents: read
      security-events: write          # upload results to code scanning
    strategy:
      fail-fast: false
      matrix:
        language: [javascript-typescript, actions]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        with:
          languages: ${{ matrix.language }}
          build-mode: none
          queries: security-extended
      - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        with:
          category: /language:${{ matrix.language }}

CodeQL uploads results; the ruleset's "Require code scanning results" rule decides whether the pull request can merge. Set it to block on High or higher security alerts to match the threshold policy in Foundations.

SCA and license policy with dependency review

Dependency review compares the dependency graph of the pull request with its base branch and fails only on what the pull request adds. That makes it a strict gate that does not punish authors for existing debt, which Dependabot alerts track separately.

.github/workflows/ci.yml · dependencies jobyaml
  dependencies:
    name: SCA (dependency review)
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    permissions:
      contents: read
    steps:
      - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
        with:
          fail-on-severity: high
          allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC

IaC scanning with Trivy

Trivy's config scan checks Terraform, Dockerfiles, Kubernetes manifests and more. Results go to code scanning as SARIF, so they appear next to CodeQL alerts. Note the two pins: the action by SHA and the Trivy binary by version.

.github/workflows/ci.yml · iac jobyaml
  iac:
    name: IaC scan (Trivy)
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Scan Terraform, Dockerfiles and manifests
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          scan-type: config
          scan-ref: .
          severity: CRITICAL,HIGH
          exit-code: "1"
          format: sarif
          output: trivy-iac.sarif
          limit-severities-for-sarif: true
          version: v0.74.0                # pin the scanner binary as well
      - name: Upload findings to code scanning
        if: ${{ !cancelled() }}           # upload even when the scan failed the job
        uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        with:
          sarif_file: trivy-iac.sarif
          category: trivy-iac

Incident · March 2026

Attackers with stolen credentials force-pushed 76 of 77 trivy-action version tags to a credential stealer (CVE-2026-33634). Workflows that referenced @0.34.2 or similar ran it; workflows pinned to a commit SHA did not. The SHA above is release v0.36.0, published after the incident. Pinning protects you from a tag being moved, so keep reviewing what a new SHA contains before you accept the update.

Secret scanning is not a job here because GitHub runs it on every push when Secret Protection is on, and push protection blocks the push before a secret reaches the repository. If you do not have Secret Protection, add a gitleaks job pinned the same way.

Step 4Release: build once, scan, attest

release.yml runs only on main. It builds the image once, refers to it by digest from then on, and records two signed attestations: SLSA build provenance and an SBOM. The image scan is the release gate. If it fails, no attestation is created, and the deploy job's verification step will refuse the image.

.github/workflows/release.yml · build jobyaml
  build:
    name: Build, scan, attest
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    permissions:
      contents: read
      packages: write             # push to GHCR
      id-token: write             # Sigstore certificate for the attestations
      attestations: write
      artifact-metadata: write
    outputs:
      digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE }}:${{ github.sha }}

      - name: Scan image (release gate)
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
          severity: CRITICAL,HIGH
          ignore-unfixed: true          # block on what you can actually fix
          exit-code: "1"
          version: v0.74.0

      - name: Generate SBOM
        uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
        with:
          image: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
          format: spdx-json
          output-file: sbom.spdx.json

      - name: Attest build provenance
        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true

      - name: Attest SBOM
        uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.push.outputs.digest }}
          sbom-path: sbom.spdx.json
          push-to-registry: true
  • Why scan after push? The image goes to the registry under a commit-SHA tag, but nothing deploys it unless it has an attestation, and attestations are only created after the scan passes. Building, scanning and pushing in separate steps works too; this order keeps one build and one digest.
  • actions/attest picks its mode from the inputs. Without sbom-path it creates SLSA provenance; with it, an SBOM attestation. actions/attest-build-provenance still works and is now a thin wrapper around it.
  • No cache in release jobs. Leave out cache: options here. A cache written by an earlier, less trusted job is an easy way to poison a release build.

Plan requirement

Artifact attestations work in public repositories on every plan. Private and internal repositories need GitHub Enterprise Cloud. Without it, sign the digest with cosign sign (keyless, using the job's OIDC token) and verify it with cosign verify in the deploy job.

Step 5Deploy with OIDC and approvals

The deploy job targets the production environment, so it waits for a required reviewer and can only run from main. It never holds a stored Azure secret: azure/login exchanges the job's OIDC token for a short-lived Azure token.

Create the trust in Microsoft Entra ID

On the app registration or user-assigned managed identity used for deploys, add a federated credential with these values. Scope the identity's Azure role to the one resource group it deploys to.

FieldValue
Issuerhttps://token.actions.githubusercontent.com
Subjectrepo:contoso/payments-api:environment:production
Audienceapi://AzureADTokenExchange

Because the subject names the environment, only jobs that passed the environment's reviewers and branch rule can obtain the token. A pull request job, which has a pull_request subject, cannot.

.github/workflows/release.yml · deploy jobyaml
  deploy:
    name: Deploy to production
    needs: build
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    environment: production       # required reviewers + main-only rule live here
    permissions:
      contents: read
      id-token: write             # OIDC token for Azure
      attestations: read
      packages: read
    env:
      IMAGE_REF: ghcr.io/contoso/payments-api@${{ needs.build.outputs.digest }}
    steps:
      - name: Verify provenance before deploying
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh attestation verify "oci://$IMAGE_REF" \
            --repo "$GITHUB_REPOSITORY" \
            --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml"

      - name: Sign in to Azure with OIDC
        uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy the verified digest
        run: |
          az containerapp update \
            --name payments-api \
            --resource-group rg-payments-prod \
            --image "$IMAGE_REF"

--signer-workflow matters. Without it, any workflow in the repository could produce a valid attestation. With it, only an image built by release.yml passes.

Step 6Keep pins fresh

SHA pinning only stays safe if updates keep flowing. Dependabot understands SHA-pinned actions and updates both the SHA and the version comment. A short cooldown means you skip versions that are pulled within days of release, which is when most hijacked releases are caught.

.github/dependabot.ymlyaml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 7            # let new releases age a week before proposing them
    groups:
      actions:
        patterns: ["*"]          # one PR for all action bumps

  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 7

  - package-ecosystem: docker
    directory: /
    schedule:
      interval: weekly

If you use Renovate instead, the helpers:pinGitHubActionDigests preset pins and updates actions the same way. For a weekly posture report, add the OpenSSF Scorecard workflow (ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4); it checks for pinned dependencies, token permissions, branch protection and more.

Step 7Scale across repositories

Copying ci.yml into fifty repositories gives you fifty files to keep secure. Move the security jobs into a reusable workflow in a central repository owned by the platform team, and call it by SHA.

.github/workflows/ci.yml (in each service repo)yaml
name: ci
on:
  pull_request:
  push:
    branches: [main]

permissions: {}

jobs:
  secure-ci:
    # Replace with the commit SHA of the release you reviewed; keep the tag as a comment.
    uses: contoso/platform-workflows/.github/workflows/secure-ci.yml@REPLACE_WITH_COMMIT_SHA # v3.2.0
    permissions:
      contents: read
      security-events: write
    with:
      language: javascript-typescript
  • Permissions only shrink. A called workflow can never get more than the caller grants, so the caller's permissions block is the ceiling.
  • Pass secrets by name. Avoid secrets: inherit, which hands every secret the caller can see to the called workflow.
  • Enforce it. An organization ruleset can require this workflow to pass before merging in every repository, so teams cannot delete it from their own ci.yml.
  • Higher provenance assurance. When signing happens inside a centrally owned reusable workflow, the build steps of the calling repository cannot forge the provenance. That is the basis for SLSA Build Level 3 on GitHub.

Pitfalls that get pipelines owned

Script injection through expressions

${{ }} is expanded into the script text before the shell runs it. Anything a contributor controls, such as titles, branch names, commit messages and issue bodies, must reach the shell as an environment variable.

Injectableyaml
- name: Greet
  run: echo "Checking PR ${{ github.event.pull_request.title }}"
Safeyaml
- name: Greet
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Checking PR $PR_TITLE"

pull_request_target and workflow_run

Both run with the base repository's token, secrets and cache access, even when a fork triggered them. Checking out and running the fork's code in that context is the classic "pwn request". Since June 2026, actions/checkout v7 refuses to check out a fork pull request's code in these events (the fix was backported to earlier majors), and the opt-out input is deliberately named allow-unsafe-pr-checkout so reviewers notice it. The block does not cover fetching the code with git or gh in a run step, so the rule stands: build untrusted code with pull_request, and treat anything it produces as data in privileged follow-up workflows.

Writing untrusted data to GITHUB_ENV

Lines appended to $GITHUB_ENV become environment variables for every later step. If any part comes from untrusted input, an attacker can set variables such as NODE_OPTIONS or LD_PRELOAD and run code in the next step. Use step outputs for untrusted values instead.

Self-hosted runners

Never attach persistent self-hosted runners to public repositories; any fork pull request can run code on them. For private repositories, use ephemeral just-in-time runners or Actions Runner Controller, put them in runner groups limited to specific repositories, and keep production-capable runners out of pull request jobs.

Complete files

Both workflows assembled, ready to adapt. Replace contoso/payments-api, the resource group and the build commands with your own.

.github/workflows/ci.ymlyaml
# Runs untrusted pull request code: read-only token, no secrets.
name: ci

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
  schedule:
    - cron: "17 3 * * 1"              # weekly rescan: new CVEs land on unchanged code

permissions: {}                        # nothing by default; each job opts in

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  test:
    name: Build and unit test
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version-file: .nvmrc
          cache: npm
      - run: npm ci --ignore-scripts     # dependency install scripts don't run
      - run: npm test

  codeql:
    name: SAST (CodeQL)
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    permissions:
      contents: read
      security-events: write
    strategy:
      fail-fast: false
      matrix:
        language: [javascript-typescript, actions]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        with:
          languages: ${{ matrix.language }}
          build-mode: none
          queries: security-extended
      - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        with:
          category: /language:${{ matrix.language }}

  dependencies:
    name: SCA (dependency review)
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    permissions:
      contents: read
    steps:
      - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
        with:
          fail-on-severity: high
          allow-licenses: MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC

  iac:
    name: IaC scan (Trivy)
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          scan-type: config
          scan-ref: .
          severity: CRITICAL,HIGH
          exit-code: "1"
          format: sarif
          output: trivy-iac.sarif
          limit-severities-for-sarif: true
          version: v0.74.0
      - if: ${{ !cancelled() }}
        uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
        with:
          sarif_file: trivy-iac.sarif
          category: trivy-iac

Checked

Every SHA on this page was resolved from the upstream release tag on 11 October 2026. When you copy a file later, let Dependabot raise the update PRs rather than editing SHAs by hand.

References

Next: the same pipeline on Azure DevOps or the hardening checklist.