Skip to content

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.

LayerWhat it doesWhere it lives
0. Baseline enterprise policyRead-only default token, no Actions-created PRs, OIDC over stored secrets, environment protectionEnterprise → Policies → Actions
1. Enterprise Actions PolicyRuntime block on non-SHA-pinned actionsEnterprise → Policies → Actions
2. PR security scanningzizmor required workflow scans every PR (Actions posture); dcg scans alongside it for destructive commands, advisory only<platform-org>/security-workflows repo
3. Enterprise rulesetRequires the zizmor workflow + gates mergesEnterprise → Policies → Repository
4. Dependabot rolloutKeeps pinned SHAs up to datePer-repo .github/dependabot.yml
  • 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.

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’s excessive-permissions findings 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: read
steps:
- 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.

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.

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.

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.

  1. Navigate to Enterprise → Policies → Actions.
  2. Under Policies, choose one of: - Allow enterprise, and select non-enterprise, actions and reusable workflows — recommended.
  3. Tick Require actions to be pinned to a full-length commit SHA.
  4. 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.
  5. Click Save.

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@v35

Pin-policy parity. The namespaces you exempt here (GitHub-created, verified Marketplace, and <org>/*) must exactly match the namespaces zizmor allows to ref-pin in 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.

  • 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-repo
because 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.

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 config

Under Settings → Actions → General → Access, set: Accessible from repositories in the enterprise.

.github/workflows/zizmor-scan.yml:

name: zizmor scan
on:
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 Scanning

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-pin namespaces 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-fix
on:
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.

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.json scripts, 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.

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 scan
on:
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.sarif

Supply-chain note: DCG_INSTALL_REF must be an organization variable holding a reviewed commit SHA of Dicklesworthstone/destructive_command_guard. A commit SHA on raw.githubusercontent.com is 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.

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/**",
]

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 scan
on:
pull_request:
branches: ['**']
merge_group:
permissions:
contents: read
security-events: write
jobs:
dcg:
uses: <platform-org>/security-workflows/.github/workflows/dcg-scan.yml@main

The 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.

  • 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:

Terminal window
dcg allowlist add core.git:reset-hard \
--reason "Approved test fixture that validates reset behavior" \
--project

The 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.

  1. Enterprise → Policies → Repository → Rulesets → New ruleset → New branch ruleset.

  2. Name: enterprise-default-branch-protection

  3. Enforcement status: start in Evaluate mode, flip to Active mode after the rollout window.

  4. Bypass list: enterprise owners only (use sparingly).

  5. Target: - Target organizations: All organizations. - Target repositories: All repositories. - Target branches: Default branch.

  6. 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>

  7. Save.

For repeatable, version-controlled provisioning. POST to /enterprises/{enterprise}/rulesets:

Terminal window
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"
}
]
}
}
]
}
JSON

Get 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: 2
updates:
- 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"

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:

  1. Bootstrap (once) — run harden.sh bootstrap to seed dependabot.yml into every repo that lacks it.
  2. Presence gate (ongoing) — an enterprise push ruleset (harden.sh presence-gate) carrying a file_path_restriction on .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 (empty updates:, wrong ecosystem) through a bypassed or pre-existing path. Give the reconcile automation a bypass_actors entry (PRESENCE_BYPASS_ACTOR_ID) before switching this ruleset to Active, or it blocks its own remediation PRs.
  3. 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.yml in 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: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
YAML
)"
# List all orgs in the enterprise
gh 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
done

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.

WeekAction
Week 0Announce 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 1Create 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 2Enable 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 3Open 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 4When 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 5Flip the ruleset to Active. This gates on zizmor; dcg keeps reporting.
OngoingRoll 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.

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.

After full rollout, smoke-test by opening a PR with this workflow in any repo:

name: smoke-test
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # ← tag-pinned, should fail

Expected outcomes:

  1. Workflow startup fails with the SHA-pinning error → Layer 1 working.
  2. Run zizmor check fails on the PR before the workflow even tries → Layer 2 working.
  3. Merge button greyed out with “Required workflow” message → Layer 3 working.
  4. 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:

  1. The dcg scan job succeeds, and a finding with a stable rule ID such as core.git:reset-hard appears in Code Scanning → Layer 2 (dcg) working. A red X here means someone raised --fail-on without also updating the ruleset.
  2. If the job reports success with no finding and no annotation, check for a dcg scan skipped warning — DCG_INSTALL_REF is unset or is not a commit SHA, and nothing scanned.
  3. The enterprise ruleset blocks merge only after it is switched from Evaluate to Active mode → Layer 3 gating working.

List repos missing zizmor coverage:

Terminal window
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:

Terminal window
gh api /enterprises/<enterprise>/rulesets --jq '.[].name'

Run zizmor locally before pushing:

Terminal window
brew install zizmor # or: cargo install zizmor
zizmor .github/workflows/
zizmor --fix=all .github/workflows/ # apply auto-fixes
  • 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.

/.github — the special org-defaults repo A repo literally named .github at the org level is recognized by GitHub as a source of org-wide defaults. Contents include:

  • 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.

/security-workflows — the shared workflows repo An ordinary internal repo (its name contains no “ .github ”), hosted inside a designated platform org, that holds the workflow YAML consumed by other repos — including the required zizmor and dcg scans from Layer 2. GitHub doesn’t treat this repo specially (the name is a convention; see Appendix D).

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- /.github/docs/ hardening.md )

SECURITY.md , contributing guides /.github/ (root)

zizmor-scan.yml , zizmor-autofix.yml , /security-workflows/.github/ dcg-scan.yml (Layer 2) workflows/

Shared zizmor.yml config /security-workflows/ (root)

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 .github in 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: warehouse

Enterprise-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, refers to whichever org you designate.

Creating the platform org If you don’t already have one:

  1. Enterprise → Organizations → New organization (you need to be an enterprise owner).
  2. Name it according to your convention.
  3. Apply the enterprise’s default policies (they cascade automatically).
  4. Add 2–3 owners from your platform/security team.

Creating the shared workflows repo inside it

  1. 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.

  2. 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.

  3. 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.

  4. 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 in commands and paths, substitute the actual org slug you chose. For example, if your platform org is acme-platform :

  • 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.

LayerBlockerWhy it bitesEscape valve
0Read-only default tokenpush/comment/upload jobs fail with 403 until permissions: is addedDeclare least-privilege permissions: per workflow; zizmor dry-run first
0No Actions-created/approved PRsrelease/changeset/format bots that self-merge breakFine-grained PAT or GitHub App token; human approver
0OIDC over stored secretsdeploys fail until per-provider trust is configuredOne-time federated-credential setup per provider; stage the cutover
0Environment reviewers/timerdeploys wait on a human approvalOn-call approver rotation
0Fork/outside-contributor approvalexternal PRs don’t run CI until a maintainer approvesMaintainer triage SLA
1SHA-pinning hard cutoverevery tag-pinned action fails at startup, no dry-runRollback switch; pinning dry-run in Weeks 2-3
1Sub-action chain breakagebreakage you didn’t cause and can’t fix locallyTemporary SHA allowlist + upstream issue
1SHAs are unreadablecan’t tell version at a glance; copy-paste from docs needs manual pinningDependabot comments/labels; a pin helper
2New required scan checkspre-existing findings block previously-green PRsEvaluate mode + remediation window before Active
2dcg/zizmor false positivesa legitimate run: step blocks the PRReviewed project allowlist entry
2auto-fix churn--fix=all rewrites shell quoting, breaks Windows runnersReview diffs; keep Windows repos out of blind auto-fix
2Central-repo dependencya bad/slow security-workflows change blocks all mergesStrict CODEOWNERS + fast review SLA on that repo
3PR + approval + linear history + no force-pushsolo/quick fixes must route through reviewed, rebased PRsStandard PR flow; pairing for reviews
3Dismiss-stale + last-push approvala re-push drops approval, causing a re-review loopBatch changes before requesting review
3Required thread resolutionone unresolved comment blocks mergeResolve or explicitly defer threads
3Enterprise overrideowners can’t relax locally; exceptions escalateFast-track exception process (below)
4Dependabot PR volumeweekly bumps add review loadGrouping; scheduled review time
4Cooldown lagcan’t pull a just-released fix immediatelyManual PR for urgent security bumps
4Content reconcilereopens/overwrites intentionally customized configChange the canonical template via PR to the platform repo

When a gate blocks legitimate work, teams need a fast, auditable path — not a silent bypass:

  1. Open a request in the platform repo (issue template: repo, gate, reason, expiry).
  2. 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.
  3. Review by platform/security with a target SLA (e.g. same business day for build-breaking blocks).
  4. 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