Skip to content

Latest commit

 

History

History
97 lines (87 loc) · 23.2 KB

File metadata and controls

97 lines (87 loc) · 23.2 KB
name github-actions
description BC Gov hardening patterns for GitHub Actions workflows — deny-all `permissions: {}` baseline with per-job step-ups, first-party tag vs third-party SHA pinning, fork-gate, single results-aggregator required check, pinned ubuntu-24.04 runners, script-injection-safe env handling, and signed-commit ruleset interaction. Covers `.github/workflows/*.yml`, `dependabot.yml`, and the branch-protection / repository-ruleset settings that pair with them. Use when authoring or reviewing a workflow in a BC Gov repo, debugging permission-denied errors, or onboarding Dependabot.
owner bcgov
tags
github-actions
ci
cd
security
bcgov
devops

GitHub Actions — BC Gov Hardening Patterns

Use When

  • Authoring or reviewing any file under .github/workflows/ in a BC Gov repo.
  • Wiring branch protection / a repository ruleset and need to pick the required status check(s).
  • Hardening a public repo's pipeline against fork-PR token exfiltration, action tag hijacks, or script injection from PR titles / branch names / issue bodies.
  • Debugging Resource not accessible by integration, Refusing to allow a Personal Access Token to create workflow, or other GITHUB_TOKEN permission errors.
  • Adding or auditing Dependabot — both .github/dependabot.yml and an auto-merge workflow for low-risk bumps.
  • Picking a runner OS / version, action pin format (tag vs SHA), or permissions: scope set for a new job.
  • Publishing artifacts, npm packages, container images, or GitHub Pages from a workflow and need OIDC / least-privilege guidance.
  • Migrating an existing repo onto BC Gov defaults (signed commits, single required check, no force-push).

Don't Use When

  • Authoring generic GitHub Actions workflows with no BC Gov / security angle — prefer the upstream GitHub Actions docs or a general CI catalogue.
  • Authoring reusable / callable workflows that deploy or validate into the BC Gov Azure Landing Zone (e.g., platform-team-owned deploy templates, OpenShift or AKS deploy callers) — defer to the BC Gov gha-workflows skill (when installed in your agent environment); this skill covers only the workflow chassis (permissions, pinning, fork-gate, aggregator, ruleset) around such callers, not their deploy logic.
  • Designing the workflow's domain logic (Bicep what-if, Terraform plan, npm publish mechanics, Pages build) — those belong to the domain skill: sibling azure-networking in this catalogue, or upstream Microsoft azure-prepare / azure-deploy skills (if installed). This skill covers only the workflow chassis around them.
  • Provisioning the Azure infrastructure the workflow deploys to — use the matching domain skill (upstream Microsoft azure-* skills, if installed).
  • Defining branch-protection rules in code via a Terraform provider — out of scope; this skill describes the settings a ruleset should carry, not the IaC to apply them.
  • Debugging self-hosted runner setup, ARC (Actions Runner Controller), or runner-image customization — out of scope.

Workflow

  1. Set repository defaults before writing any workflow. In the repo's Settings → Actions → General: set Workflow permissions to Read repository contents and packages permissions (default-deny write); leave Allow GitHub Actions to create and approve pull requests off unless a workflow truly needs it; under Fork pull request workflows from outside collaborators pick Require approval for first-time contributors who are new to GitHub at minimum. These defaults make every later permissions: block additive instead of subtractive.
  2. Pin the runner OS to a specific version label, never the *-latest aliases. Use runs-on: ubuntu-24.04 for Linux jobs, runs-on: windows-2025 (or windows-2022) for Windows jobs, and runs-on: macos-26 (or macos-15) for macOS jobs — or whichever specific label the repo standardises on. A pinned runner makes the OS upgrade a deliberate PR you review, not a silent rollover during an incident; ubuntu-latest, windows-latest, and macos-latest each roll forward on GitHub's own cadence and the breaking-change blast radius differs by platform (Xcode majors and SDK churn on macOS, Visual Studio component updates on Windows, glibc / Python / Node default bumps on Ubuntu).
  3. Pin actions by class: first-party actions owned by GitHub (actions/*) pin to a major tag (actions/checkout@v6) — GitHub guarantees those tags are forward-only and Dependabot still bumps majors. Third-party actions pin to an immutable commit SHA with the human-readable version in a trailing comment (uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0) so a tag hijack cannot swap the action's code under you.
  4. Declare top-level least-privilege permissions: — prefer the deny-all permissions: {} baseline. Start every workflow with permissions: {} (empty mapping = deny everything) and add only the specific scopes each job needs (e.g. contents: read on the checkout job, packages: write on the publish job, pages: write + id-token: write on the deploy job, pull-requests: write on the bot-comment job). Anything that doesn't appear in the block is implicitly denied. If a workflow legitimately needs contents: read everywhere (e.g. all jobs check out the repo), set that at workflow scope and keep every elevation per-job.
  5. Harden every non-trivial run: block with set -euo pipefail as the first line (Bash is the default shell on ubuntu-* runners). Chain related commands with && rather than newlines when you want them to be one logical step that stops on the first failure. Without pipefail, a failure on the left side of a | is silently dropped; without -u, an unset variable becomes an empty string that quietly corrupts commands.
  6. Add a fork-gate as the first job on any pull_request workflow in a public repo. The job compares github.event.pull_request.head.repo.full_name to github.repository and exit 1s when they differ, so maintainers re-land fork PRs from an in-repo branch after review. This keeps CI minutes (and any future token) off untrusted code.
  7. Wire a results aggregator job with if: always(), needs: [every-other-job], and a step that fails when any needs.*.result is failure or cancelled. Make this single job the only required status check in branch protection — then renaming or splitting upstream jobs never requires touching the ruleset. Treat skipped as a pass so conditional jobs don't false-fail the gate.
  8. Constrain the trigger surface. On push/pull_request, add paths: filters so the workflow only runs when files it cares about change (skills/** for the publish workflow, docs/** for Pages, etc.). On scheduled or long-running workflows add concurrency: { group: "<stable-key>", cancel-in-progress: <true|false> }true for build/test, false for deploy / publish to avoid mid-flight cancellation.
  9. Pass untrusted input only through env:, never via ${{ … }} interpolation in a run: block. PR titles, branch names, issue bodies, and PR head SHAs are attacker-controlled. Bind them to environment variables in an env: map and reference them with shell quoting ("$PR_TITLE") so a ; rm -rf / payload becomes a literal string, not a command.
  10. Use pull_request_target only when you must mutate the PR (approve, label, auto-merge) and never combine it with actions/checkout of the PR head without an explicit reviewer gate — pull_request_target runs with the base repo's tokens. Limit those workflows to if: github.event.pull_request.user.login == 'dependabot[bot]' (or an equivalent allow-list) and a tight concurrency group to avoid race conditions on synchronize.
  11. Configure Dependabot in two files. .github/dependabot.yml enables the github-actions ecosystem (and any others — npm, pip, uv, docker) so every pinned action and runtime gets PR-bumped, and uses a Conventional-Commit commit-message.prefix keyed to the ecosystem (deps(actions), deps(npm), deps(pip), deps(uv)). Pair it with a small dependabot-auto-merge.yml triggered on pull_request_target that approves and enables auto-merge only for dependabot[bot] PRs, with a deny-all permissions: {} at the workflow scope and permissions: { contents: write, pull-requests: write } scoped to the single auto-merge job.
  12. Require signed commits on main. Either via the GitHub branch ruleset (Settings → Rules → Rulesets) or the legacy branch protection page: enable Require signed commits, Require a pull request before merging, point Require status checks at the single results job, set Require strict status checks, and disable Allow force pushes and Allow deletions. Signed commits + a single required check is the BC Gov default — keep workflow PRs themselves signed so they don't lock you out.
  13. Use Conventional Commits for workflow / action PRs, with the scope derived from the primary directory touched: ci(workflows): ... for .github/workflows/** edits, ci(scripts): ... for .github/scripts/**, deps(actions): ... for pinned-action bumps. This is what the BC Gov coding standard expects and what Dependabot is already configured to emit — keep human PRs consistent so changelogs and release-notes generation stay clean.
  14. Validate locally before opening a PR. Lint YAML (yamllint .github/workflows/), shellcheck any non-trivial run: blocks (or use actionlint which extracts and shellchecks them for you), and dry-run with act for fast iteration on the matrix surface. For this repo specifically, also run make lint test validate so the results check passes on the first push.

Rules

  • Always pin first-party actions/* to a major tag, third-party actions to an immutable commit SHA + version comment, and runner tools/binaries to a specific release version (never pipe unpinned installer scripts from raw CDN URLs). (Why: GitHub forward-guarantees actions/* major tags; third-party tags are mutable and a hijacked release can ship an exfil payload to every workflow that uses it; unpinned CDN scripts are a security and rate-limiting risk. Dependabot bumps both actions styles, so you lose nothing.)
  • Always declare a top-level permissions: block — permissions: {} (deny-all) as the strongest default, or permissions: { contents: read } when every job legitimately needs to read the repo — and step up to any write scope only on the single job that needs it. (Why: workflows inherit the repo's Workflow permissions default; setting it at workflow scope makes least-privilege a code-review artefact instead of a UI checkbox that can drift. permissions: {} is the BC Gov standard baseline.)
  • Always start non-trivial run: blocks with set -euo pipefail and chain commands you want short-circuited with &&. (Why: Bash without pipefail silently swallows failures upstream of a |; without -u an unset variable becomes "" and a missing secret turns into a quietly successful no-op instead of a loud failure. && makes the dependency between commands part of the shell script, not assumed.)
  • Always pin the runner to a specific OS version label — ubuntu-24.04 for Linux, windows-2025 (or windows-2022) for Windows, macos-26 (or macos-15) for macOS — never the *-latest aliases (ubuntu-latest, windows-latest, macos-latest). (Why: every *-latest alias silently rolls to a new major version on GitHub's own cadence — Ubuntu LTS roughly yearly, Windows Server every few years, macOS once a year — and the breaking-change blast radius is real on every platform: Xcode majors and SDK churn on macOS, Visual Studio component updates on Windows, glibc / Python / Node default bumps on Ubuntu. A pinned label makes the upgrade a deliberate, reviewable PR instead of an incident-time surprise. GitHub only supports the latest two GA images per OS, so the pinned label also tells you when the OS itself is approaching deprecation.)
  • Always make a single results aggregator job the only required status check in branch protection. (Why: required-check names are duplicated into the ruleset by string; renaming or splitting any underlying job is otherwise a coordinated change across two systems. The aggregator decouples them and if: always() lets it run even when an upstream job fails.)
  • Always add a fork-gate job that fails fast on PRs opened from a fork in any public-repo pull_request workflow. (Why: even with permissions: read, fork PRs spend CI minutes and any future expansion of the workflow's privileges immediately becomes a token-exfil hole. Maintainers re-land fork work from an in-repo branch.)
  • Always pass attacker-controlled values (github.event.pull_request.title, head_ref, issue.body, comment.body) through env: and quote them in shell. (Why: ${{ … }} is interpolated by the runner before the shell parses, so a payload like "; curl …" becomes a command, not a string. env: + "$VAR" is the only safe pattern.)
  • Always check the actor on a pull_request_target workflow before doing anything that uses the elevated token. (Why: pull_request_target runs in the base repo's context with its secrets; without a strict actor gate, a fork PR can trigger a privileged path.)
  • Never check out the PR's head SHA on a pull_request_target workflow and then run code from it (build/test/install hooks). (Why: that re-introduces the untrusted code into a privileged context — the exact attack pull_request_target was designed around.)
  • Never use a long-lived Personal Access Token where the built-in GITHUB_TOKEN or OIDC will work. (Why: PATs grant their owner's full permissions, don't expire on PR close, and bypass the per-workflow permissions: ceiling; they also block on the owner leaving the org.)
  • Always log into a public cloud (Azure or AWS) via OIDC federation, not a stored secret credential. Grant id-token: write on the deploy job only, use the official provider action SHA-pinned per Rule #1 (azure/login or aws-actions/configure-aws-credentials), and pin the cloud-side trust by a subject claim scoped to repo + branch / environment (repo:OWNER/REPO:environment:prod is the strongest shape because it composes with GitHub Environments' required-reviewers gate). (Why: a long-lived AZURE_CLIENT_SECRET or AWS access key in secrets.* is a stealable, non-expiring, environment-blind credential — exactly the blast-radius profile that an action-tag hijack, a pull_request_target slip, or an insider is designed to harvest. OIDC tokens expire in ~5 minutes, are minted only when id-token: write is granted on a specific job, and the cloud-side subject pins the GitHub repo + ref so a fork or a non-main branch cannot exchange them.)
  • Never disable signed-commit enforcement on main, even temporarily. (Why: the BC Gov ruleset treats it as a hard floor; disabling it for one merge requires re-enabling and means any unsigned commit that landed during the gap is permanent.)

Examples

  • "Add CI to a new repo so PRs run lint and tests" → workflow with on: pull_request, permissions: { contents: read }, jobs fork-gate → lint-tests → results, actions/checkout@v6 + a pinned setup action, runs-on: ubuntu-24.04, single required check = results.
  • "Publish an npm package from main on a path change" → workflow with on: push: { branches: [main], paths: ["packages/**"] }, permissions: { contents: read, packages: write } on the publish job only, actions/setup-node@v6 with registry-url: https://npm.pkg.github.com, NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}, and a version-skip step that no-ops when the version is already on the registry.
  • "Deploy GitHub Pages from docs/" → workflow with permissions: { contents: read, pages: write, id-token: write } at workflow scope, concurrency: { group: pages, cancel-in-progress: false }, build job → deploy job, actions/configure-pages@v6, actions/upload-pages-artifact@v5, actions/deploy-pages@v5.
  • "Deploy to Azure / AWS from a workflow without storing cloud secrets" → OIDC federation on the deploy job: permissions: { contents: read, id-token: write }, environment: prod, then uses: azure/login@<sha> # v3.0.0 with only client-id / tenant-id / subscription-id (no client-secret), or uses: aws-actions/configure-aws-credentials@<sha> # v6 with role-to-assume + aws-region (no aws-access-key-id / aws-secret-access-key). Cloud-side: create a Federated Identity Credential (Entra) or IAM OIDC trust (AWS) whose subject pins the workflow — prefer repo:OWNER/REPO:environment:prod so the OIDC token is only mintable after the Environment's required-reviewers gate runs.
  • "Branch protection started failing after I renamed a job" → required check name no longer matches; collapse all jobs behind a single results aggregator and require only that one. Future renames stop touching the ruleset.
  • "Dependabot auto-merge isn't merging the PR" → check the trigger is pull_request_target (not pull_request), the actor gate is dependabot[bot] literally, the workflow has permissions: { contents: write, pull-requests: write }, and the repo allows auto-merge in Settings → General → Pull Requests.
  • "Resource not accessible by integration on a gh pr review step" → the job is missing permissions: { pull-requests: write }; add it at job scope, not workflow scope, so the rest of the workflow stays read-only.

Edge Cases

  • The repo predates the Workflow permissions: read default → existing workflows may inherit write-all even with no permissions: block. Add an explicit permissions: { contents: read } to every workflow before flipping the repo default, otherwise jobs that quietly relied on the implicit write will start failing on push.
  • A required action only ships a tag, never a release SHA you can trust → fork the action into a BC Gov-owned repo, pin the fork by SHA, and let Dependabot track upstream for review. Pinning a mutable tag on a third-party action is the explicit threat the SHA pin defends against.
  • A workflow needs to write to the repo and read a secret only on main → split into two jobs. Keep permissions: { contents: read } at workflow scope; on the privileged job add the narrower permissions: block and gate it with if: github.ref == 'refs/heads/main' && github.event_name == 'push' so PR runs never see the elevated scope.
  • Dependabot PRs can't see secrets → that is by design. If a Dependabot PR's CI needs a secret to pass, route it through a separate scheduled / manual workflow that runs against main after merge, or grant the secret to Dependabot specifically via Settings → Secrets and variables → Dependabot.
  • A required results check is reporting skipped after a refactor → branch protection treats skipped as success by design (so conditional jobs don't false-fail the gate). If you genuinely want skipped to block, make the aggregator's failure step also exit 1 on contains(needs.*.result, 'skipped').
  • Signed-commit policy blocks a Dependabot or auto-merge PR → either enable Dependabot's signed-commit support (it signs as web-flow via the GitHub API) or have the auto-merge workflow create a signed merge commit through gh pr merge --auto --squash so the merge itself is signed by GitHub even when the PR commits weren't.
  • pull_request_target workflow needs the PR's code (e.g. to read a config file the PR added) → check out the base ref, then read only the specific files you trust from the PR using gh api or git show <pr-sha>:<path> into a variable; never actions/checkout the head SHA with the default token still in scope.
  • A third-party action requires a GITHUB_TOKEN with write you don't want to grant → most of the time you don't actually need it; pass with: token: ${{ github.token }} only on the step that needs it, and prefer a minimal permissions: block on that job over expanding workflow-scope perms.
  • OIDC cloud login fails with AADSTS70021: No matching federated identity record found (Azure) or Not authorized to perform sts:AssumeRoleWithWebIdentity (AWS) — the cloud-side federated-credential subject claim doesn't match the workflow's actual sub. Fetch the OIDC token from inside the deploy job and decode its payload to see the live sub (the runner exposes the JWT-issuing endpoint via $ACTIONS_ID_TOKEN_REQUEST_URL and an opaque bearer in $ACTIONS_ID_TOKEN_REQUEST_TOKEN):
    curl -sSL -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" \
      | jq -r .value \
      | cut -d. -f2 \
      | base64 -d 2>/dev/null \
      | jq .sub
    Or enable ACTIONS_STEP_DEBUG: true and re-read the provider action's (azure/login, aws-actions/configure-aws-credentials) own debug output, which prints the subject it sent. Update the Federated Identity Credential / IAM trust to match — typically repo:OWNER/REPO:environment:NAME for environment-gated deploys, repo:OWNER/REPO:ref:refs/heads/main for branch-gated, or repo:OWNER/REPO:pull_request for PR-time runs (rarely granted). Never unblock by adding a client-secret / aws-access-key-id — that silently regresses the deploy to a stored credential.

References

This repo is the canonical worked example. Read .github/workflows/pr.yml, .github/workflows/pages.yml, and .github/workflows/dependabot-auto-merge.yml first — they implement every pattern above, fully commented.

See references/REFERENCE.md for an annotated end-to-end PR workflow with fork-gate + aggregator, a publish workflow with version-skip and least-privilege scoping, the Dependabot config + auto-merge pair, an OIDC cloud-login worked example (Azure / AWS) with a federated-credential subject-claim cookbook, a permissions: scope lookup table, a pinning-style decision matrix, the branch-protection / ruleset settings checklist, and a threat-model section mapping each rule to the attack it defends against (tag hijack, fork-PR token exfil, script injection, pull_request_target abuse, PAT sprawl, long-lived cloud-secret exfil).

For broader topics that live elsewhere, prefer these upstream skills / docs instead of duplicating their guidance here. Precedence: when generic upstream guidance below conflicts with this skill, the BC Gov rule wins (e.g. the deny-all permissions: {} baseline, third-party-action SHA pinning, the single results required check, signed-commit enforcement on main).

  • Generic GitHub Actions CI/CD chassis (workflow structure, jobs, matrix, caching, deployment strategies) → github-actions-ci-cd-best-practices from github/awesome-copilot
  • Generic GitHub Actions syntax, triggers, expressions → GitHub Actions docs
  • Hardening guidance from GitHub itself → Security hardening for GitHub Actions
  • OIDC-to-Azure federation for keyless deploys → upstream Microsoft azure-prepare / azure-deploy skills (if installed)
  • Authoring the skill profile this workflow ships → .github/skills/skill-author