GitHub Actions Hardening - Enterprise-Wide Runbook
A layered hardening strategy for GitHub Enterprise Cloud, applied across all organizations in the enterprise: a baseline enterprise policy (Layer 0) plus four additive layers. Together they set least-privilege defaults, enforce SHA-pinning, scan every PR for insecure Actions usage and committed destructive commands, gate merges, and keep pinned SHAs fresh.
| Layer | What it does | Where it lives |
|---|---|---|
| 0. Baseline enterprise policy | Read-only default token, no Actions-created PRs, OIDC over stored secrets, environment protection | Enterprise → Policies → Actions |
| 1. Enterprise Actions Policy | Runtime block on non-SHA-pinned actions | Enterprise → Policies → Actions |
| 2. PR security scanning | zizmor required workflow scans every PR (Actions posture); dcg scans alongside it for destructive commands, advisory only | <platform-org>/security-workflows repo |
| 3. Enterprise ruleset | Requires the zizmor workflow + gates merges | Enterprise → Policies → Repository |
| 4. Dependabot rollout | Keeps pinned SHAs up to date | Per-repo .github/dependabot.yml |
Prerequisites
Section titled “Prerequisites”- Plan: GitHub Enterprise Cloud (you have this).
- Role: Enterprise owner — needed for enterprise-level policies and rulesets.
- Platform org: repos in GitHub live inside organizations, not directly under an enterprise.
Designate one org in your enterprise (referred to throughout as
<platform-org>) to host shared workflows and tooling. If you don’t have one yet, create it via Enterprise → Organizations → New organization. See Appendix D for the full pattern. - Central repo: In the platform org, create one internal repo,
<platform-org>/security-workflows, to host shared workflows (internal visibility, so all internal/private repos in the enterprise can consume it). - Rollout window: Plan to run rulesets in Evaluate mode for ~2 weeks before flipping to Active mode.
Layer 0 - Baseline enterprise policy
Section titled “Layer 0 - Baseline enterprise policy”Set these enterprise defaults first. They are low-effort, high-leverage, and mostly independent of the four layers below, so enable them in Week 0.
Default GITHUB_TOKEN permissions: read-only
Section titled “Default GITHUB_TOKEN permissions: read-only”Enterprise → Policies → Actions → Workflow permissions → set the default to Read repository
contents. Workflows that need more declare an explicit top-level permissions: block, making
least-privilege the floor. zizmor’s excessive-permissions rule (Layer 2) then flags anything that
over-asks.
Mildly breaking: workflows that push, comment, or upload without declaring
permissions:will fail until they add a block. Use zizmor’sexcessive-permissionsfindings as the dry-run before flipping the default — the same pattern as the Layer 1 pinning dry-run.
Disable Actions creating and approving pull requests
Section titled “Disable Actions creating and approving pull requests”Enterprise → Policies → Actions → uncheck Allow GitHub Actions to create and approve pull requests. This closes a path where a workflow opens a PR and self-approves it, bypassing the Layer 3 review requirement.
OIDC for cloud auth (no long-lived secrets)
Section titled “OIDC for cloud auth (no long-lived secrets)”Replace stored cloud credentials with short-lived OIDC tokens. The cloud trusts GitHub’s OIDC
issuer scoped to a specific repo:<org>/<repo> subject; nothing long-lived is stored.
permissions: id-token: write contents: readsteps: - uses: azure/login@<SHA> with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}The same pattern applies to aws-actions/configure-aws-credentials, google-github-actions/auth,
and HashiCorp Vault. See ADR 0003.
Environment protection rules
Section titled “Environment protection rules”For deploy environments, require reviewers and (optionally) a wait timer so environment secrets are only reachable behind an approval gate, not from any branch that can trigger a workflow.
Fork and outside-contributor PRs
Section titled “Fork and outside-contributor PRs”Keep Require approval for all outside collaborators (or first-time contributors) enabled so fork PRs don’t run workflows automatically. Required workflows still run on forks, but without secrets.
Optional: runner egress hardening
Section titled “Optional: runner egress hardening”For sensitive repos, add step-security/harden-runner@<SHA> with an egress allowlist to block
unexpected network calls at runtime — a runtime complement to dcg’s static command scanning.
Layer 1 - Enterprise Actions Policy (SHA pinning)
Section titled “Layer 1 - Enterprise Actions Policy (SHA pinning)”This is the runtime enforcement. Once enabled, any workflow run that references an action by tag will fail before it executes.
⚠ No Evaluate mode. Unlike rulesets (Layer 3), the Actions policy is a single on/off checkbox. The moment you save, every non-SHA-pinned workflow across every repo in the enterprise starts failing. There is no dry-run, no “would-have-blocked” insights view, and no per-org gradual rollout knob. Use zizmor (Layer 2) as the pinning dry-run before enabling this — count unpinned-uses findings across all repos, fix them via auto-fix PRs, then flip the switch. The rollout plan below sequences this correctly: Layer 1 is enabled in Week 4, after the bulk of repos have been remediated.
Configuration
Section titled “Configuration”- Navigate to Enterprise → Policies → Actions.
- Under Policies, choose one of: - Allow enterprise, and select non-enterprise, actions and reusable workflows — recommended.
- Tick Require actions to be pinned to a full-length commit SHA.
- Under the allowlist, enable: - ✓ Allow actions created by GitHub - ✓ Allow Marketplace actions by verified creators - + Add explicit allowlist entries for trusted non-verified actions you depend on.
- Click Save.
Allowlist examples
Section titled “Allowlist examples”The allowlist supports wildcards. Add entries like:
actions/*github/*docker/*softprops/action-gh-release@*hashicorp/setup-terraform@*azure/login@*<org>/*To block a specific compromised version, prefix with !:
!tj-actions/changed-files@v35Pin-policy parity. The namespaces you exempt here (GitHub-created, verified Marketplace, and
<org>/*) must exactly match the namespaces zizmor allows toref-pinin its Layer 2 config. If the two sets drift, zizmor will pass a tag-pin that this policy then hard-blocks at runtime (or vice versa). See Layer 2, Step 3.
Caveats
Section titled “Caveats”-
Reusable workflows can still reference tags — only uses: for actions is enforced.
-
Local actions ( ./path/to/action and ./.github/actions/x ) are exempt.
-
The policy validates the full dependency tree, including sub-actions inside the actions you call. Expect a few surprise failures from popular actions that haven’t pinned their own dependencies. File issues upstream or temporarily allowlist by SHA.
-
Enterprise-level setting overrides org and repo settings. Org owners cannot relax it.
Error you’ll see when an unpinned action is used:
Error: The actions example/some-action@v1 are not allowed in your-org/your-repobecause all actions must be pinned to a full-length commit SHA.Layer 2 - Centralized PR security scanning
Section titled “Layer 2 - Centralized PR security scanning”The Actions policy only enforces pinning. This layer runs two centrally hosted workflows on every PR, each covering a different class of problem:
- zizmor scan — GitHub Actions security posture: template injection, excessive permissions, artipacked, dangerous triggers, ref/version mismatches, secrets misuse, and unpinned uses. This one is a required workflow and gates the merge.
- dcg scan — destructive shell commands committed to
run:blocks and other executable contexts. Advisory only: it reports to Code Scanning but never blocks a merge, and it is not in the Layer 3 ruleset. See ADR 0006 for why, and for the conditions under which it would be promoted.
Both live in <platform-org>/security-workflows and upload SARIF to Code Scanning. Only the
zizmor scan is gated by the Layer 3 ruleset.
Step 1: Create the shared workflows repo
Section titled “Step 1: Create the shared workflows repo”Create <platform-org>/security-workflows (internal visibility).
<platform-org>/security-workflows/├── .github/│ └── workflows/│ ├── zizmor-scan.yml # the reusable zizmor scan workflow│ ├── zizmor-autofix.yml # weekly auto-fix that opens PRs│ └── dcg-scan.yml # the reusable dcg scan workflow├── zizmor.yml # shared zizmor config└── .dcg/hooks.toml # shared dcg configUnder Settings → Actions → General → Access, set: Accessible from repositories in the enterprise.
Step 2: Add the zizmor scan workflow
Section titled “Step 2: Add the zizmor scan workflow”.github/workflows/zizmor-scan.yml:
name: zizmor scanon: pull_request: branches: ['**'] push: branches: [main]
permissions: {}
jobs: zizmor: name: Run zizmor runs-on: ubuntu-latest permissions: contents: read security-events: write # upload SARIF actions: read
steps: - name: Checkout uses: actions/checkout@<SHA> # pin this with: persist-credentials: false
- name: Run zizmor uses: zizmorcore/zizmor-action@<SHA> # pin this with: advanced-security: true # uploads SARIF to Code ScanningStep 3: Add a shared zizmor config
Section titled “Step 3: Add a shared zizmor config”zizmor.yml (consumed by zizmor-action automatically when present). In this config the literal
ref-pin means “tag-pin allowed” and hash-pin means “SHA-pin required”:
rules: unpinned-uses: config: policies: # ref-pin (tag-pin allowed) for first-party and trusted internal actions "actions/*": ref-pin "github/*": ref-pin "<org>/*": ref-pin # Everything else: hash-pin (SHA-pin required) "*": hash-pin
template-injection: ignore: []
excessive-permissions: ignore: []Pin-policy parity. The
ref-pinnamespaces above must exactly match the namespaces exempted in the Layer 1 allowlist (GitHub-created, verified Marketplace, and<org>/*). Change them together, never separately — otherwise zizmor and the Actions policy disagree.
Step 4 (optional): Auto-fix workflow
.github/workflows/zizmor-autofix.yml — runs weekly, opens PRs with zizmor --fix=all:
name: zizmor auto-fixon: schedule: - cron: '0 6 * * 1' workflow_dispatch:
permissions: {}
jobs: fix: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@<SHA> with: persist-credentials: false
- name: Install zizmor run: | curl -LsSf https://github.com/zizmorcore/zizmor/releases/latest/download/zizmor-installer.sh | sh
- name: Run auto-fix env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: zizmor --fix=all .
- name: Open PR uses: peter-evans/create-pull-request@<SHA> with: token: ${{ secrets.ZIZMOR_PAT }} # PAT with `workflow` scope commit-message: 'chore: apply zizmor --fix=all' branch: chore/zizmor-autofix title: 'chore: apply zizmor security fixes' body: | Automated SHA-pinning and security fixes from zizmor. Review the diff carefully — especially shell quoting changes on Windows runners.Why a PAT? The default GITHUB_TOKEN cannot modify files under .github/workflows/. Store
a fine-grained PAT or GitHub App token with workflows: write as the secret ZIZMOR_PAT.
The dcg scan
Section titled “The dcg scan”zizmor covers Actions security posture; the dcg scan (destructive_command_guard) adds a narrower check for destructive command strings inside executable repository contexts. In GitHub Actions, dcg extracts run: blocks from workflows, then evaluates the commands with its destructive-command rule engine.
- Primary target: destructive shell commands committed to
.github/workflows/*.yml. - Secondary targets: shell scripts, Dockerfiles, Makefiles,
package.jsonscripts, Terraform local-exec blocks, Docker Compose commands, PowerShell, and batch files. - Rollout posture: advisory. Scan changed files with
--fail-on none, report to Code Scanning, and review the findings before considering anything stronger.
Scope: the dcg scan is repository scanning only. It intentionally does not cover VS Code hooks, Copilot Chat hooks, developer workstation installs, or local pre-commit hooks. Those are separate controls.
The dcg scan workflow
Section titled “The dcg scan workflow”This sits next to zizmor-scan.yml in <platform-org>/security-workflows/.github/workflows/. Unlike zizmor it is not named in the Layer 3 ruleset and never fails its job, so a finding is a signal to a reviewer rather than a merge gate. It also refuses to run at all unless DCG_INSTALL_REF is set to a full-length commit SHA — the installer is a remote script piped into bash, and an advisory signal does not justify unreviewed code execution across the enterprise.
.github/workflows/dcg-scan.yml:
name: dcg repository scanon: workflow_call: pull_request: branches: ['**'] merge_group:
permissions: contents: read security-events: write
jobs: dcg: name: Run dcg scan runs-on: ubuntu-latest steps: - name: Check the install ref is pinned id: pin env: DCG_INSTALL_REF: ${{ vars.DCG_INSTALL_REF }} run: | set -euo pipefail if printf '%s' "$DCG_INSTALL_REF" | grep -Eq '^[0-9a-f]{40}$'; then echo "pinned=true" >> "$GITHUB_OUTPUT" else echo "pinned=false" >> "$GITHUB_OUTPUT" echo "::warning::dcg scan skipped: DCG_INSTALL_REF is not a commit SHA." fi
- name: Checkout if: steps.pin.outputs.pinned == 'true' uses: actions/checkout@<SHA> with: fetch-depth: 0 persist-credentials: false
- name: Install dcg if: steps.pin.outputs.pinned == 'true' env: DCG_INSTALL_REF: ${{ vars.DCG_INSTALL_REF }} run: | DCG_BASE="https://raw.githubusercontent.com" DCG_REPO="Dicklesworthstone/destructive_command_guard" curl -fsSL "$DCG_BASE/$DCG_REPO/$DCG_INSTALL_REF/install.sh" | bash echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Scan changed executable contexts if: steps.pin.outputs.pinned == 'true' run: | BASE="origin/${{ github.base_ref || github.event.merge_group.base_ref }}" dcg scan --git-diff "$BASE..HEAD" \ --format sarif \ --fail-on none \ > dcg.sarif
- name: Upload SARIF if: always() && steps.pin.outputs.pinned == 'true' uses: github/codeql-action/upload-sarif@<SHA> with: sarif_file: dcg.sarifSupply-chain note:
DCG_INSTALL_REFmust be an organization variable holding a reviewed commit SHA ofDicklesworthstone/destructive_command_guard. A commit SHA onraw.githubusercontent.comis immutable, which is the same guarantee Layer 1 demands of actions. Mirroring the installer internally is stronger still. Do not point this at a branch.The trade is that upstream moves quickly, so the pin will lag, and updating it means reviewing a diff. That is acceptable for an advisory scan; it would not be for a merge gate.
The dcg scan config
Section titled “The dcg scan config”Commit the shared config in the platform workflow repo first. Repos can override with their own project config only through reviewed PRs.
.dcg/hooks.toml:
[scan]fail_on = "none"format = "sarif"redact = "quoted"truncate = 120
[scan.paths]include = [ ".github/workflows/**", "Dockerfile", "Dockerfile.*", "Makefile", "scripts/**", "package.json",]exclude = [ "node_modules/**", "vendor/**", "target/**", "dist/**",]Calling the workflow from pilot repos
Section titled “Calling the workflow from pilot repos”During the pilot, add a tiny caller workflow to 3-5 representative repos. After the pilot, bootstrap the same file broadly with automated PRs.
.github/workflows/dcg-scan.yml in each repo:
name: dcg repository scanon: pull_request: branches: ['**'] merge_group:
permissions: contents: read security-events: write
jobs: dcg: uses: <platform-org>/security-workflows/.github/workflows/dcg-scan.yml@mainThe dcg scan is not a required workflow, so the per-repo caller is how it runs at all. Bootstrap it wherever you want the signal; repositories without it simply do not get scanned.
Tuning findings
Section titled “Tuning findings”- Prefer code changes: replace destructive cleanup commands with narrower paths or safer recovery flows.
- Use project allowlists sparingly: if a command is intentional, add a reviewed project-level allowlist with a reason.
- Leave the fail threshold at
none: raising it is a change of posture, not a tuning knob, and requires restoring the workflow to the Layer 3 ruleset at the same time. See ADR 0006. - Track false positives centrally: maintain a short known-findings log in the platform repo. Whether the noise floor is tolerable is the main evidence for or against promoting the scan later.
Example project-level exception:
dcg allowlist add core.git:reset-hard \ --reason "Approved test fixture that validates reset behavior" \ --projectThe zizmor scan is wired into merge protection in Layer 3; the dcg scan rolls out on the same schedule as a reporting-only signal — see the unified Rollout plan and Verification.
Sources: github.com/Dicklesworthstone/destructive_command_guard README repository scanning and GitHub Actions sections; GH-hardening rollout plan.
Layer 3 - Enterprise ruleset requiring the workflows
Section titled “Layer 3 - Enterprise ruleset requiring the workflows”This layer ties Layers 1 and 2 to merge protection: PRs cannot merge unless the required zizmor workflow runs and passes. The dcg scan is deliberately not listed here — see ADR 0006.
Configuration via UI
Section titled “Configuration via UI”-
Enterprise → Policies → Repository → Rulesets → New ruleset → New branch ruleset.
-
Name: enterprise-default-branch-protection
-
Enforcement status: start in Evaluate mode, flip to Active mode after the rollout window.
-
Bypass list: enterprise owners only (use sparingly).
-
Target: - Target organizations: All organizations. - Target repositories: All repositories. - Target branches: Default branch.
-
Rules — enable: - ✓ Restrict deletions - ✓ Require linear history - ✓ Require a pull request before merging (1 approval, dismiss stale approvals on push, require review from Code Owners if applicable) - ✓ Block force pushes - ✓ Require workflows to run — add
<platform-org>/security-workflows/.github/workflows/zizmor-scan.yml@<SHA-or-tag> -
Save.
Configuration via API
Section titled “Configuration via API”For repeatable, version-controlled provisioning. POST to /enterprises/{enterprise}/rulesets:
gh api \ --method POST \ -H "Accept: application/vnd.github+json" \ /enterprises/<enterprise>/rulesets \ --input - <<'JSON'{ "name": "enterprise-default-branch-protection", "target": "branch", "enforcement": "evaluate", "conditions": { "organization_name": { "include": ["~ALL"], "exclude": [] }, "repository_name": { "include": ["~ALL"], "exclude": [] }, "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" }, { "type": "required_linear_history" }, { "type": "pull_request", "parameters": { "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, "require_last_push_approval": true, "required_review_thread_resolution": true } }, { "type": "workflows", "parameters": { "workflows": [ { "repository_id": <REPO_ID_OF_SECURITY_WORKFLOWS_REPO>, "path": ".github/workflows/zizmor-scan.yml", "ref": "refs/heads/main" } ] } } ]}JSONGet the repository ID and default branch with gh api /repos/<platform-org>/security-workflows --jq '[.id, .default_branch]'. The ref must name that branch: refs/heads/main above is correct only if security-workflows actually defaults to main. harden.sh ruleset resolves both for you.
After 2 weeks in Evaluate mode Review insights at Enterprise → Rulesets → [ruleset] → Insights. Fix repos that would have been blocked. Then PATCH the ruleset to “enforcement”: “active” (Active mode).
Layer 4 - Dependabot rollout for SHA updates
Section titled “Layer 4 - Dependabot rollout for SHA updates”SHA pinning is only useful if the SHAs stay current. Dependabot updates them while respecting a cooldown period (gives the community time to spot compromised releases).
Per-repo file: .github/dependabot.yml:
version: 2updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly day: monday cooldown: default-days: 7 # wait 7 days after release before opening update PR semver-major-days: 14 open-pull-requests-limit: 10 groups: actions-minor-patch: update-types: ["minor", "patch"] commit-message: prefix: "chore(deps)" include: "scope"Enforcement: presence vs. content
Section titled “Enforcement: presence vs. content”Enforce Dependabot with one composite flow. Each part guarantees something different, and a ruleset alone is not enough — rulesets gate refs and paths, not file content:
- Bootstrap (once) — run
harden.sh bootstrapto seeddependabot.ymlinto every repo that lacks it. - Presence gate (ongoing) — an enterprise push ruleset (
harden.sh presence-gate) carrying afile_path_restrictionon.github/dependabot.yml. Note that GitHub has no deletion-only rule for a path: this blocks any push touching the file, not just deletion. It guarantees the file survives; it does not guarantee the contents are correct, because someone can gut the file (emptyupdates:, wrong ecosystem) through a bypassed or pre-existing path. Give the reconcile automation abypass_actorsentry (PRESENCE_BYPASS_ACTOR_ID) before switching this ruleset to Active, or it blocks its own remediation PRs. - Content reconcile (ongoing) —
harden.sh reconcile, run on a schedule, re-asserts the canonical config and opens a PR wherever a repo’s copy has drifted. This is the only part that guarantees config content.
If you want zero enforcement, skip steps 2 and 3 and just publish
dependabot.ymlin a template repo — but that guarantees neither presence nor content, so it is not recommended for an enterprise mandate.
Bootstrap script (step 1) — the minimal shape, shown for reference. scripts/harden.sh bootstrap is the maintained implementation: it paginates the organization list, creates the branch before pushing to it, uses each repo’s real default branch, and runs on macOS.
#!/usr/bin/env bash# bootstrap-dependabot.sh — open PRs adding dependabot.yml to every repo
ENTERPRISE="<enterprise>"DEPENDABOT_FILE="$(cat <<'YAML'version: 2updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly cooldown: default-days: 7YAML)"
# List all orgs in the enterprisegh api graphql -f query=' query($slug: String!) { enterprise(slug: $slug) { organizations(first: 100) { nodes { login } } } }' -f slug="$ENTERPRISE" --jq '.data.enterprise.organizations.nodes[].login' \| while read -r ORG; do # List all repos in each org gh repo list "$ORG" --limit 1000 --json name --jq '.[].name' \ | while read -r REPO; do if ! gh api "/repos/$ORG/$REPO/contents/.github/dependabot.yml" >/dev/null 2>&1; then echo "Adding dependabot.yml to $ORG/$REPO" # Use a PAT or App token here gh api --method PUT \ "/repos/$ORG/$REPO/contents/.github/dependabot.yml" \ -f message="chore: add Dependabot config for GitHub Actions" \ -f content="$(echo "$DEPENDABOT_FILE" | base64 -w0)" \ -f branch="chore/add-dependabot" || true gh pr create --repo "$ORG/$REPO" \ --title "chore: add Dependabot config" \ --body "Automated rollout of Dependabot for GitHub Actions updates." \ --head "chore/add-dependabot" --base main || true fi done doneRollout plan
Section titled “Rollout plan”A single staged rollout covers all layers so you don’t break every repo on day one. Sequencing matters: Layer 1 (Actions policy) has no Evaluate mode and breaks non-SHA-pinned workflows the instant it’s enabled, so it must come after zizmor has surfaced and fixed the bulk of unpinned references. zizmor and dcg roll out together, but only zizmor becomes a required workflow — dcg is repository scanning only, advisory, and not a workstation hook requirement.
| Week | Action |
|---|---|
| Week 0 | Announce in internal channels. Publish this runbook and share the zizmor docs link. Apply the Layer 0 baseline policy (read-only token, disable Actions-created PRs, OIDC, environment protection). Clarify that dcg is repository scanning only, advisory, and not a workstation hook. |
| Week 1 | Create the security-workflows repo (Layer 2). Land the zizmor-scan.yml and dcg-scan.yml workflows. Review a dcg release and set the DCG_INSTALL_REF organization variable to its commit SHA — until then the dcg scan skips itself. Open zizmor auto-fix PRs and pilot dcg in the same 3–5 pilot repos. |
| Week 2 | Enable the enterprise ruleset in Evaluate mode (Layer 3) with zizmor required. Run zizmor across all repos to inventory unpinned-uses findings — the pinning dry-run for Layer 1 — and inventory dcg findings via SARIF/Code Scanning. |
| Week 3 | Open remediation PRs across all repos (zizmor auto-fix + dcg findings). Triage and merge. Record which dcg findings were true positives; that record is the evidence for any later decision to make it blocking. |
| Week 4 | When zizmor unpinned-uses findings and high-severity dcg findings approach zero, enable the Enterprise Actions Policy SHA-pinning (Layer 1). Expect some breakage from sub-action chains zizmor doesn’t fully cover; have a fast-track allowlist process and an on-call rotation ready for the first 48 hours. |
| Week 5 | Flip the ruleset to Active. This gates on zizmor; dcg keeps reporting. |
| Ongoing | Roll out Dependabot config (Layer 4). Keep dcg running on PRs and merge queues, and re-review DCG_INSTALL_REF when you want a newer release. Expand dcg scan paths only after Dependabot is stable and teams have handled initial noise. Monitor weekly. |
Rollback plan for Layer 1
Section titled “Rollback plan for Layer 1”If Week 4 enablement causes widespread breakage, Layer 1 can be disabled instantly by unchecking Require actions to be pinned to a full-length commit SHA in the enterprise policy. Workflows resume on the next run. Use this as an escape valve while you triage — but don’t leave it off; the protection only works when enabled.
Verification
Section titled “Verification”After full rollout, smoke-test by opening a PR with this workflow in any repo:
name: smoke-teston: pull_requestjobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # ← tag-pinned, should failExpected outcomes:
- Workflow startup fails with the SHA-pinning error → Layer 1 working.
- Run zizmor check fails on the PR before the workflow even tries → Layer 2 working.
- Merge button greyed out with “Required workflow” message → Layer 3 working.
- Weekly Dependabot PRs appearing in repos → Layer 4 working.
To verify dcg scanning, open a separate test PR that adds git reset --hard HEAD inside a workflow run: block:
- The dcg scan job succeeds, and a finding with a stable rule ID such as
core.git:reset-hardappears in Code Scanning → Layer 2 (dcg) working. A red X here means someone raised--fail-onwithout also updating the ruleset. - If the job reports success with no finding and no annotation, check for a
dcg scan skippedwarning —DCG_INSTALL_REFis unset or is not a commit SHA, and nothing scanned. - The enterprise ruleset blocks merge only after it is switched from Evaluate to Active mode → Layer 3 gating working.
Appendix A - Useful CLI snippets
Section titled “Appendix A - Useful CLI snippets”List repos missing zizmor coverage:
gh api graphql -f query=' query($org: String!) { organization(login: $org) { repositories(first: 100) { nodes { name defaultBranchRef { name } } } } }' -f org=<org>Check enterprise ruleset status:
gh api /enterprises/<enterprise>/rulesets --jq '.[].name'Run zizmor locally before pushing:
brew install zizmor # or: cargo install zizmorzizmor .github/workflows/zizmor --fix=all .github/workflows/ # apply auto-fixesAppendix B - Common pitfalls
Section titled “Appendix B - Common pitfalls”- Sub-action breakage: third-party actions that internally use tag-pinned sub-actions will break under Layer 1. Workaround: temporarily allowlist by SHA while you file an upstream issue.
- Windows shell quoting: zizmor —fix=all may produce PowerShell-incompatible env var quoting. Review diffs before merging on Windows-runner repos.
- Forks and contributor PRs: required workflows still run on fork PRs but secrets are unavailable — make sure the zizmor workflow doesn’t depend on secrets for the scan path.
- Local actions ( ./… ): exempt from SHA pinning enforcement, but still scanned by zizmor.
- Reusable workflows: NOT covered by SHA enforcement (by design). If you want them pinned too, add a custom zizmor rule or scan step.
- Merge queues: if you use them, ensure the zizmor workflow includes merge_group in its triggers, otherwise queued PRs won’t trigger it.
Appendix C - Which repo (or directory) is which
Section titled “Appendix C - Which repo (or directory) is which”Three things in this runbook are easy to conflate: a repo literally named .github, the .github/
directory that every repo has, and the shared security-workflows repo. They are distinct and
serve different purposes.
- Community health files — CODE_OF_CONDUCT.md , CONTRIBUTING.md , SECURITY.md , SUPPORT.md , FUNDING.yml , plus issue and PR templates. If a repo in the org doesn’t have its own copy, GitHub serves the one from .github .
- Org profile — profile/README.md renders on the org’s public page.
- Internal docs and governance — onboarding guides, runbooks (like this one), policy documents. Not auto-applied, but it’s the conventional, predictable place developers look.
Tip: don’t name this repo .github-workflows or anything containing .github — that invites
exactly the confusion with the org .github repo that this appendix warns against.
security-workflows is deliberately distinct.
Note: repositories cannot live directly under an enterprise — they always live inside an organization. See Appendix D for the enterprise/org/repo hierarchy.
Why keep them separate
REASON DETAIL
Different change Workflows ship often; governance docs rarely. cadence
Different reviewers CODEOWNERS for workflows is usually platform/security; docs is broader.
Different protection The workflows repo needs stricter branch protection — a bad commit there affects every repo in the enterprise.
Different visibility A required workflow’s visibility (public/internal/private) determines which repos constraints can call it. Mixing this constraint with the docs repo limits both.
Where to put each thing in this runbook
ARTIFACT GOES IN
This runbook ( github-actions-
SECURITY.md , contributing guides
zizmor-scan.yml , zizmor-autofix.yml ,
Shared zizmor.yml config
Per-repo dependabot.yml (Layer 4) Each individual repo’s .github/ directory
And then there’s .github/ the directory Every repo also has a .github/ directory (note: directory, not repo) for that repo’s own workflows, templates, and configs. So the three distinct things are:
/.github = a special repo for org-wide defaults /.github/ = a directory inside any repo for that repo’s GitHub-specific files /security-workflows = an ordinary repo (no .githubin its name) hosting shared workflow YAML
When this runbook says “.github/workflows/” in a path, it means the directory inside whatever
repo is being discussed. Avoid the bare phrase “the central .github repo”; always qualify it as
either “<org>/.github (org-defaults repo)” or “security-workflows (shared workflows repo)”.
Appendix D - Enterprises don’t hold repos: the platform-org pattern
Section titled “Appendix D - Enterprises don’t hold repos: the platform-org pattern”A common point of confusion: GitHub has no such thing as an “enterprise repo.” Repositories only exist inside organizations (or under user accounts). An enterprise is a container for orgs, billing, identity, and policies — not a place that holds repos directly.
The hierarchy
Enterprise (e.g. acme-inc)├── Organization: acme-product ← repos live here│ ├── repo: frontend│ └── repo: backend├── Organization: acme-platform ← and here│ ├── repo: security-workflows ← shared workflows go here│ └── repo: terraform-modules└── Organization: acme-data └── repo: warehouseEnterprise-level features (policies, rulesets, allowlists, SAML, billing) cascade down to all orgs. But you cannot create a repo at the enterprise level.
The platform-org pattern For shared workflows, tooling, and platform code, designate one org in your enterprise to act as the “platform” or “shared infrastructure” org. Common naming patterns:
-platform -infra -shared -ci -tooling
Throughout this runbook,
Creating the platform org If you don’t already have one:
- Enterprise → Organizations → New organization (you need to be an enterprise owner).
- Name it according to your convention.
- Apply the enterprise’s default policies (they cascade automatically).
- Add 2–3 owners from your platform/security team.
Creating the shared workflows repo inside it
-
In the platform org: New repository → name it security-workflows → Internal visibility. - Internal means all members of the enterprise can read it, but it’s not public. This is what allows required workflows in other orgs’ repos to reference it.
-
Settings → Actions → General → Access → Accessible from repositories in the enterprise. This is the key setting. Without it, other orgs cannot use the workflows hosted here, and your enterprise ruleset (Layer 3) will fail to invoke them.
-
Set strict branch protection on the default branch — require PRs, require review from platform/security CODEOWNERS, require signed commits, restrict who can push. A bad commit here propagates to every repo that requires the workflow.
-
Add a CODEOWNERS file that routes all workflow changes to the platform/security team.
Why a separate platform org rather than reusing an existing one
REASON DETAIL
Blast radius Compromise of the platform org affects every CI run in the enterprise. Keeping it minimal-membership reduces that risk.
Audit clarity Activity logs for shared tooling stay separate from product development noise.
Permission Engineers don’t need write access to platform tooling to ship features. Tightly model scoped membership enforces this.
Billing visibility If you ever break out Actions minutes by org, platform overhead is cleanly separated from product use.
Mapping to the runbook
Wherever you see
-
Shared workflows repo: acme-platform/security-workflows
-
Required workflow path in ruleset: acme-platform/security-workflows/.github/workflows/ zizmor-scan.yml@
-
Repo ID lookup: gh api /repos/acme-platform/security-workflows —jq .id
Appendix E - Developer friction and mitigations
Section titled “Appendix E - Developer friction and mitigations”Hardening adds gates, and most “it’s blocking my work” pain is one of the items below. Each has a defined escape valve. Publish this table with the rollout so teams know the valve before they hit the wall.
| Layer | Blocker | Why it bites | Escape valve |
|---|---|---|---|
| 0 | Read-only default token | push/comment/upload jobs fail with 403 until permissions: is added | Declare least-privilege permissions: per workflow; zizmor dry-run first |
| 0 | No Actions-created/approved PRs | release/changeset/format bots that self-merge break | Fine-grained PAT or GitHub App token; human approver |
| 0 | OIDC over stored secrets | deploys fail until per-provider trust is configured | One-time federated-credential setup per provider; stage the cutover |
| 0 | Environment reviewers/timer | deploys wait on a human approval | On-call approver rotation |
| 0 | Fork/outside-contributor approval | external PRs don’t run CI until a maintainer approves | Maintainer triage SLA |
| 1 | SHA-pinning hard cutover | every tag-pinned action fails at startup, no dry-run | Rollback switch; pinning dry-run in Weeks 2-3 |
| 1 | Sub-action chain breakage | breakage you didn’t cause and can’t fix locally | Temporary SHA allowlist + upstream issue |
| 1 | SHAs are unreadable | can’t tell version at a glance; copy-paste from docs needs manual pinning | Dependabot comments/labels; a pin helper |
| 2 | New required scan checks | pre-existing findings block previously-green PRs | Evaluate mode + remediation window before Active |
| 2 | dcg/zizmor false positives | a legitimate run: step blocks the PR | Reviewed project allowlist entry |
| 2 | auto-fix churn | --fix=all rewrites shell quoting, breaks Windows runners | Review diffs; keep Windows repos out of blind auto-fix |
| 2 | Central-repo dependency | a bad/slow security-workflows change blocks all merges | Strict CODEOWNERS + fast review SLA on that repo |
| 3 | PR + approval + linear history + no force-push | solo/quick fixes must route through reviewed, rebased PRs | Standard PR flow; pairing for reviews |
| 3 | Dismiss-stale + last-push approval | a re-push drops approval, causing a re-review loop | Batch changes before requesting review |
| 3 | Required thread resolution | one unresolved comment blocks merge | Resolve or explicitly defer threads |
| 3 | Enterprise override | owners can’t relax locally; exceptions escalate | Fast-track exception process (below) |
| 4 | Dependabot PR volume | weekly bumps add review load | Grouping; scheduled review time |
| 4 | Cooldown lag | can’t pull a just-released fix immediately | Manual PR for urgent security bumps |
| 4 | Content reconcile | reopens/overwrites intentionally customized config | Change the canonical template via PR to the platform repo |
Fast-track exception process
Section titled “Fast-track exception process”When a gate blocks legitimate work, teams need a fast, auditable path — not a silent bypass:
- Open a request in the platform repo (issue template: repo, gate, reason, expiry).
- Scope it narrowly — a single SHA allowlist entry, one dcg project allowlist rule with a reason, or a time-boxed ruleset bypass — never a blanket disable.
- Review by platform/security with a target SLA (e.g. same business day for build-breaking blocks).
- Expire and revisit — every exception carries an expiry; the known-findings log tracks open exceptions so they don’t become permanent.
The goal is to keep the gate on for everyone while giving a predictable release valve, so hardening doesn’t get quietly rolled back under deadline pressure. See ADR 0004.
References
- GitHub: Enforcing policies for GitHub Actions in your enterprise
- GitHub: Available rules for rulesets
- zizmor: docs.zizmor.sh
- zizmor-action: github.com/zizmorcore/zizmor-action
- Trail of Bits: We hardened zizmor’s GitHub Actions static analyzer
📦 Source: soderlind/gh-hardening · Edit on GitHub