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.
Layout
Section titled “Layout”- CONTEXT.md — the glossary: canonical terms, and the wordings to avoid.
- docs/GH-hardening-with-dcg-integrated.md — the runbook: Layer 0 baseline plus four additive layers, the rollout plan, and appendices.
- docs/architecture.md — how the pieces fit together, the invariants a change must preserve, and the known gaps.
- docs/adr/ — architecture decision records.
- scripts/harden.sh — implements the runbook against the GitHub API.
- scripts/build-docs.sh — renders the runbook to
.docxand.pdfwith pandoc. - templates/security-workflows/ — the files
pushed to
<platform-org>/security-workflows.
The layers
Section titled “The layers”- 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.
Getting started
Section titled “Getting started”Requires gh (authenticated) and jq. Optional:
shellcheck and actionlint for harden.sh lint, pandoc plus a LaTeX engine
for build-docs.sh.
cp harden.conf.example harden.conf # set ENTERPRISE, PLATFORM_ORG, REF_PIN_NAMESPACES./scripts/harden.sh verifyharden.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:
gh auth refresh -h github.com -s admin:enterpriseharden.sh
Section titled “harden.sh”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 defaultGITHUB_TOKEN; block Actions-created pull requests.scanning(Layer 2) — create<platform-org>/security-workflowsand 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.ymlto 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.ymlwherever it has drifted.pin-templates— resolve action tags to full commit SHAs intemplates/.verify— reportPASS/FAIL/MANUALper 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:
./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 scheduleWhat it cannot do
Section titled “What it cannot do”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.
Pin-policy parity
Section titled “Pin-policy parity”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.ymlis generated from that list byscanning, so it cannot drift. It is not a template and must not be hand-edited.verifyprints 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.
Templates and SHA pins
Section titled “Templates and SHA pins”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 # v4pin-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:
bootstrapseeds the file where it is missing.presence-gatekeeps it from being removed.reconcilerestores its contents where they have drifted. Run it on a schedule;verifyreports 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.
Decisions
Section titled “Decisions”- 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.ymlfrom a single namespace declaration - 0006 — run dcg as an advisory scan rather than a merge gate
Building the runbook
Section titled “Building the runbook”./scripts/build-docs.sh # writes outputs/*.docx and outputs/*.pdfHIGHLIGHT=zenburn ./scripts/build-docs.shFonts are auto-detected; override with PDF_MAINFONT / PDF_MONOFONT. The PDF
is skipped if no LaTeX engine is installed. outputs/ is gitignored.
Contributing
Section titled “Contributing”- Use the vocabulary in CONTEXT.md. It lists the canonical term for each concept and the wordings to avoid.
- Run
./scripts/harden.sh lintbefore committing. - Add an ADR only when a decision is hard to reverse, surprising without context, and the result of a real trade-off.
📦 Source: soderlind/gh-hardening · Edit on GitHub