Skip to content

Hardening GitHub Actions across an enterprise

The runbook, the decisions behind it, and a script that implements the parts GitHub exposes an API for. Covers every organization in a GitHub Enterprise Cloud account.

  • Layer 0 — baseline enterprise policy. Read-only default token, no Actions-created PRs, OIDC over stored secrets, environment protection.
  • Layer 1 — Enterprise Actions Policy. Runtime block on non-SHA-pinned actions.
  • Layer 2 — PR security scanning. The zizmor required workflow scans every pull request; the dcg scan runs alongside it as an advisory signal.
  • Layer 3 — enterprise ruleset. Requires the zizmor workflow and gates merges.
  • Layer 4 — Dependabot rollout. Keeps pinned SHAs current.

Sequencing matters. Layer 1 has no Evaluate mode — the moment it is enabled, every tag-pinned workflow in the enterprise starts failing. It is therefore enabled last, after zizmor’s unpinned-uses findings (the pinning dry-run) have been driven to near zero. See the runbook’s rollout plan.

Requires gh (authenticated) and jq. Optional: shellcheck and actionlint for harden.sh lint, pandoc plus a LaTeX engine for build-docs.sh.

Terminal window
cp harden.conf.example harden.conf # set ENTERPRISE, PLATFORM_ORG, REF_PIN_NAMESPACES
./scripts/harden.sh verify

harden.conf is gitignored. Every setting can be overridden from the environment with a HARDEN_ prefix (HARDEN_ENTERPRISE=acme).

Enterprise-level subcommands need the admin:enterprise scope:

Terminal window
gh auth refresh -h github.com -s admin:enterprise

Dry-run by default. Every mutating call is printed instead of executed unless you pass --apply. That is independent of Evaluate mode: --apply on ruleset really creates the ruleset, but in its non-blocking state.

  • baseline (Layer 0) — read-only default GITHUB_TOKEN; block Actions-created pull requests.
  • scanning (Layer 2) — create <platform-org>/security-workflows and push the scanning workflows and configs.
  • ruleset (Layer 3) — create the branch ruleset in Evaluate mode.
  • ruleset-activate (Layer 3) — flip that ruleset Evaluate to Active.
  • bootstrap (Layer 4) — open PRs adding .github/dependabot.yml to every repo that lacks it.
  • presence-gate (Layer 4) — push ruleset protecting .github/dependabot.yml.
  • reconcile (Layer 4) — open PRs restoring the canonical .github/dependabot.yml wherever it has drifted.
  • pin-templates — resolve action tags to full commit SHAs in templates/.
  • verify — report PASS / FAIL / MANUAL per control.
  • lint — shellcheck and actionlint.

Useful options: --apply, --update (allow changing an existing ruleset), --org NAME, --include-forks, --include-archived. Run ./scripts/harden.sh --help for the full list.

Typical sequence, mapped to the runbook’s rollout weeks:

Terminal window
./scripts/harden.sh baseline --apply # week 0
./scripts/harden.sh scanning --apply # week 1
./scripts/harden.sh ruleset --apply # week 2, Evaluate mode
# ... remediate, then enable Layer 1 by hand ...
./scripts/harden.sh ruleset-activate --apply # week 5
./scripts/harden.sh bootstrap --apply # ongoing
./scripts/harden.sh reconcile --apply # ongoing, on a schedule

Some controls are unautomatable — GitHub exposes them only through the web UI, with no stable API:

  • The Layer 1 “Require actions to be pinned to a full-length commit SHA” toggle and its allowlist
  • Environment protection rules
  • The outside-collaborator approval default
  • security-workflows → Settings → Actions → General → Access

verify reports these as MANUAL and prints the values to enter. MANUAL never affects the exit status, so verify is usable as a CI gate; it exits 1 only when an automatable check FAILs.

The namespaces zizmor allows to tag-pin must exactly equal the namespaces the Layer 1 allowlist exempts from SHA-pinning. Declare them once, in REF_PIN_NAMESPACES:

  • zizmor.yml is generated from that list by scanning, so it cannot drift. It is not a template and must not be hand-edited.
  • verify prints the matching Layer 1 allowlist block for you to paste into the enterprise UI, which is the half no script can reach.

See ADR 0005.

The files under templates/security-workflows/ are the source of truth for what scanning pushes. Action references are SHA-pinned, with the resolved tag kept in a trailing comment:

uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

pin-templates reads that comment to know what to re-resolve, so do not remove it. After the files land in security-workflows, that repo’s own Dependabot keeps the pins current.

scanning seeds files directly on first creation, then opens a PR for any later difference — it never clobbers, and it keeps working once the Layer 3 ruleset blocks direct pushes.

Dependabot: presence and content are different guarantees

Section titled “Dependabot: presence and content are different guarantees”

A repo can gut .github/dependabot.yml — empty updates:, wrong ecosystem — without deleting it, and no ruleset can see that. So Layer 4 uses three mechanisms, none of which is sufficient alone:

  • bootstrap seeds the file where it is missing.
  • presence-gate keeps it from being removed.
  • reconcile restores its contents where they have drifted. Run it on a schedule; verify reports presence and content as separate checks.

One sharp edge: GitHub push rulesets have no deletion-only rule for a path. The available file_path_restriction blocks every push touching .github/dependabot.yml, including reconcile’s own pull requests. Set PRESENCE_BYPASS_ACTOR_ID to the App or team that runs reconcile before switching the ruleset to Active. presence-gate creates it in Evaluate mode and warns when the bypass is unset.

See ADR 0002.

  • 0001 — enforce scanners via required workflows, not per-repo status checks
  • 0002 — enforce Dependabot config via presence gate plus content reconcile
  • 0003 — use OIDC for cloud auth instead of stored long-lived secrets
  • 0004 — provide a fast-track exception process instead of standing bypasses
  • 0005 — generate zizmor.yml from a single namespace declaration
  • 0006 — run dcg as an advisory scan rather than a merge gate
Terminal window
./scripts/build-docs.sh # writes outputs/*.docx and outputs/*.pdf
HIGHLIGHT=zenburn ./scripts/build-docs.sh

Fonts are auto-detected; override with PDF_MAINFONT / PDF_MONOFONT. The PDF is skipped if no LaTeX engine is installed. outputs/ is gitignored.

  • Use the vocabulary in CONTEXT.md. It lists the canonical term for each concept and the wordings to avoid.
  • Run ./scripts/harden.sh lint before committing.
  • Add an ADR only when a decision is hard to reverse, surprising without context, and the result of a real trade-off.