Architecture
Current state of the repository at commit 6f729ce. Anything not yet
implemented is called out under Known gaps
rather than described as if it worked.
Purpose
Section titled “Purpose”This repository is not the hardened system. It is the control plane for one: a prose runbook, a shared vocabulary, five decision records, a set of workflow templates, and one driver script that applies and verifies the controls against a GitHub Enterprise Cloud account.
The system under management is the enterprise itself. Its real state lives in
GitHub — enterprise policies, enterprise rulesets, the
<platform-org>/security-workflows repository, and a .github/dependabot.yml
in every repository. This repository holds the desired state and the code that
reconciles toward it.
Included:
- The vocabulary that keeps the runbook internally consistent.
- The runbook: Layer 0 baseline plus four additive layers.
- A dry-run-by-default driver that applies and verifies each layer.
- The workflow templates published to
<platform-org>/security-workflows. - Rendering the runbook to
.docxand.pdf.
Not included:
- Any persistent state of its own. There is no database, no server, and no CI
workflow in this repository;
outputs/holds build artifacts and is gitignored. - The unautomatable controls. GitHub exposes no stable API for the Layer 1 SHA-pin toggle, environment protection rules, or the outside-collaborator approval default. See “Unautomatable control” in CONTEXT.md.
- Scheduling.
reconcileis the mechanism ADR 0002 calls for, but nothing here runs it on a timer; that is the operator’s cron or scheduled workflow.
Major components
Section titled “Major components”Vocabulary
Section titled “Vocabulary”Location: CONTEXT.md
Defines every domain term used across the runbook, the ADRs, and the driver’s
output, each with an explicit _Avoid_ list of rejected synonyms. It is the
authority for naming: Evaluate mode, SHA-pin, pin-policy parity,
presence gate, unautomatable control, and the <enterprise> /
<platform-org> / <org> placeholders.
Changing a term here means changing it in the runbook, the ADRs, and the driver’s user-visible strings in the same commit.
Runbook
Section titled “Runbook”Location: docs/GH-hardening-with-dcg-integrated.md
The narrative specification: prerequisites, Layer 0 through Layer 4, the week-by-week rollout plan, a Layer 1 rollback plan, a manual verification smoke test, and five appendices.
The rollout order is a hard constraint, not a preference. Layer 1 has no
Evaluate mode and breaks every non-SHA-pinned workflow the instant it is
enabled, so it lands in week 4 — after zizmor’s unpinned-uses findings have
been counted and remediated. That count is the “pinning dry-run”.
Decisions
Section titled “Decisions”Location: docs/adr/
Six accepted ADRs covering the non-obvious mechanism choices: required
workflows over status checks (0001), the composite Dependabot enforcement flow
(0002), OIDC over stored secrets (0003), the fast-track exception process
(0004), generating zizmor.yml from a single declaration (0005), and running
dcg as an advisory scan rather than a merge gate (0006).
Driver
Section titled “Driver”Location: scripts/harden.sh
A single bash script, targeted at bash 3.2 so it runs under stock macOS
/bin/bash. It applies each layer through gh, and reports per-control status
through verify. Detailed below.
Templates
Section titled “Templates”Location: templates/security-workflows/
The desired contents of <platform-org>/security-workflows:
zizmor-scan.yml (the one required workflow), dcg-scan.yml (advisory),
zizmor-autofix.yml (optional, weekly), and .dcg/hooks.toml.
Every uses: reference is SHA-pinned with a trailing tag comment. The
templates are the source of truth; the published repository is a copy, and
verify reports any divergence as FAIL.
Local configuration
Section titled “Local configuration”Location: harden.conf.example, copied to
harden.conf (gitignored)
Nine keys: ENTERPRISE, PLATFORM_ORG, SECURITY_REPO,
REF_PIN_NAMESPACES, RULESET_NAME, PRESENCE_RULESET_NAME,
PRESENCE_BYPASS_ACTOR_ID, PRESENCE_BYPASS_ACTOR_TYPE, and
REQUIRED_APPROVALS. Environment variables named HARDEN_<KEY> override the
file; HARDEN_CONF relocates it.
Document renderer
Section titled “Document renderer”Location: scripts/build-docs.sh
Renders the runbook with pandoc into outputs/. Independent of everything
else — it reads only the runbook markdown, and no other component depends on
it.
Inside the driver
Section titled “Inside the driver”scripts/harden.sh has five internal tiers. Calls go downward only; nothing
in a lower tier calls a cmd_* function.
- Entry point —
mainparses the subcommand and flags, loads configuration, runs preflight, and dispatches. - Commands —
cmd_baseline,cmd_scanning,cmd_ruleset,cmd_ruleset_activate,cmd_bootstrap,cmd_presence_gate,cmd_reconcile,cmd_pin_templates,cmd_verify,cmd_lint. One per subcommand; each owns its own preconditions. - Desired-state builders —
ruleset_body,generate_zizmor_config,dependabot_config,stage_layer2. Pure: they emit the intended artifact and touch no network. - GitHub helpers —
list_orgs,list_repos,repo_exists,default_branch,remote_file_sha,remote_file_matches,put_file,create_branch,find_ruleset_id. - Foundation — configuration (
load_conf,apply_env_overrides), validation (valid_slug,need_enterprise,need_platform_org,need_namespaces), preflight (require_tools,load_token_scopes,require_scopes), portability (b64,b64d), and output (info,step,warn,die,skipped,pass,fail,manual,run).
The three mutation gates
Section titled “The three mutation gates”Nothing mutates unless --apply set APPLY=1. That check appears in exactly
three places:
run— the wrapper for directghcalls; without--applyit prints aprintf '%q 'quoting of the command.put_file— checksAPPLYitself, because the request body has to be base64-encoded and the blobshalooked up before the call is well-formed.create_branch— same reason: the base head SHA must be resolved first.
Any new side effect must route through one of these three. A raw gh api --method POST in a cmd_* function would silently break the dry-run
guarantee, which is the script’s central safety property.
Flow: publishing the scanning workflows
Section titled “Flow: publishing the scanning workflows”Trigger: scripts/harden.sh scanning --apply
mainsetsAPPLY=1, callsload_confthenapply_env_overrides, thenrequire_toolsandload_token_scopes.cmd_scanningcallsneed_platform_organdneed_namespacesto validate the slugs, thenrequire_scopes repo workflow.stage_layer2copiestemplates/security-workflows/into a temp directory and writesgenerate_zizmor_configoutput tozizmor.ymlbeside it.zizmor.ymlexists only here — there is no such file in the repository.repo_existsdecides the mode. Absent: create the repository withvisibility=internalandauto_init=true, then seed every file directly to the default branch. Present: resolve the default branch viadefault_branch, and route changes through a timestampedchore/security-workflows-sync-*branch.- For each staged file,
remote_file_matchesbase64-decodes the remote copy andcmps it against the local one. Identical files are skipped, so a no-op run opens no pull request. - On the first differing file,
create_branchcreates the ref from the base head SHA. Thenput_filewrites each file, supplying the existing blobshaonly when updating. - If anything changed and the repository already existed,
gh pr createopens the sync pull request.
Failure behavior: the temp directory is removed by an EXIT trap. A gh
failure inside put_file propagates under set -e and aborts the run; no
partial state is rolled back, but every step is idempotent, so re-running
converges.
Flow: gating a pull request
Section titled “Flow: gating a pull request”This is the runtime behavior the repository exists to produce. No code here participates; it is listed so the pieces can be traced to their sources.
- A developer opens a pull request in any repository in the enterprise.
- The Layer 3 enterprise ruleset — created by
cmd_ruleset, targeting~ALLorganizations and~DEFAULT_BRANCH— injects the required workflow by repository ID and path, at the refcmd_rulesetresolved fromsecurity-workflows’ own default branch. zizmor-scan.ymlruns zizmor against the pull request and uploads SARIF to Code Scanning. Itsunpinned-usespolicy comes from the generatedzizmor.yml.dcg-scan.ymlruns from each repository’s own workflow directory, diffs against the base ref and runsdcg scan --fail-on none, uploading SARIF. It is advisory: it is not in the ruleset and cannot block the merge. It skips itself entirely unlessDCG_INSTALL_REFis a full commit SHA. See ADR 0006.- Layer 1, if enabled, independently refuses to start any workflow step whose
uses:is not SHA-pinned. This is a runtime block, not a check. - In Evaluate mode the ruleset records violations in Insights and the merge
proceeds. After
cmd_ruleset_activate, it blocks the merge.
Boundaries and constraints
Section titled “Boundaries and constraints”Configuration is data, not code. load_conf parses KEY=value lines and
dispatches through a case whitelist. It never sources the file and never
calls eval, so a harden.conf cannot execute anything.
Slugs are validated before interpolation. valid_slug rejects any value
containing a character outside [A-Za-z0-9._-], and need_namespaces applies
a similar check to REF_PIN_NAMESPACES. Every slug reaching an API path has
passed one of them. This also catches an unreplaced <enterprise> placeholder.
gh’s exit code is the only trustworthy failure signal. On an HTTP error
gh api writes the error body to stdout and ignores --jq. Any helper that
treats output as data must gate on the exit code first — remote_file_sha,
find_ruleset_id, and the Layer 0 check in cmd_verify all do. Reading
--jq output directly makes every missing file look present.
Enforcement mode is owned by one command. cmd_ruleset deletes
.enforcement from its update payload, so re-running it can never downgrade a
live ruleset from Active to Evaluate. Only cmd_ruleset_activate writes that
field, and it refuses to run if the ruleset does not exist.
Existing rulesets are not silently rewritten. cmd_ruleset normalizes both
sides with jq -S, prints a diff -u, and then dies unless --update was
passed.
bash 3.2, BSD userland. No associative arrays, no mapfile, no base64 -w0. b64 pipes through tr -d '\n'; b64d probes for -d and falls back
to -D.
Scope preflight fails early. require_scopes reads X-OAuth-Scopes from
the /rate_limit response headers and dies with the exact gh auth refresh
command, rather than failing 300 repositories into a loop. If the header is
absent — a fine-grained PAT or App token — it warns and continues.
Invariants
Section titled “Invariants”Pin-policy parity
Section titled “Pin-policy parity”Rule: the namespaces zizmor allows to ref-pin must exactly equal the
namespaces the Layer 1 Actions policy exempts from SHA-pinning.
Enforced by: generate_zizmor_config, which derives the unpinned-uses
policies from REF_PIN_NAMESPACES and appends "*": hash-pin. The zizmor side
cannot drift because the file is generated, never stored.
Not enforced on: the Layer 1 allowlist, which has no API. cmd_verify prints
the block to paste into the enterprise UI, so it is transcribed rather than
independently authored, but nothing reads it back. See ADR 0005.
Evaluate before Active
Section titled “Evaluate before Active”Rule: a ruleset reaches Active only through a deliberate, separate command.
Enforced by: ruleset_body hardcoding enforcement: "evaluate"; cmd_ruleset
stripping .enforcement on update; cmd_ruleset_activate warning before the
flip.
The trailing tag comment is load-bearing
Section titled “The trailing tag comment is load-bearing”Rule: a pinned reference must keep its # <tag> comment.
pin_file matches uses: owner/repo@<ref> with an optional trailing comment
and prefers the comment over the ref when resolving. Strip the comment and the
only remaining reference is the SHA, which cannot be re-resolved to a newer
release — the pin becomes permanently frozen.
Templates are the source of truth
Section titled “Templates are the source of truth”Rule: <platform-org>/security-workflows matches
templates/security-workflows/ plus the generated zizmor.yml.
Enforced by: remote_file_matches in both cmd_scanning (which opens a
correcting pull request) and cmd_verify (which reports FAIL). A direct edit
in the published repository is reverted by the next scanning run.
Presence and content are separate guarantees
Section titled “Presence and content are separate guarantees”Rule: a repository is compliant with Layer 4 only if .github/dependabot.yml
both exists and matches the canonical config.
Enforced by: cmd_bootstrap (seeds where missing), cmd_presence_gate (keeps
it from being removed), and cmd_reconcile (restores drifted content).
cmd_verify reports presence and content as two separate checks, because a
gutted file passes the first and fails the second.
Sharp edge: the presence gate’s file_path_restriction blocks every push
touching that path, including cmd_reconcile’s own pull requests. The bypass
entry configured by PRESENCE_BYPASS_ACTOR_ID is what keeps the two mechanisms
from cancelling each other out. See ADR 0002.
Where to make changes
Section titled “Where to make changes”Change the tag-pinnable namespaces. Edit REF_PIN_NAMESPACES in
harden.conf, run harden.sh scanning to open the sync PR, and paste the
allowlist that harden.sh verify prints into the enterprise Actions policy.
Never edit zizmor.yml in the published repository.
Change a scanning workflow. Edit the file under
templates/security-workflows/.github/workflows/, run harden.sh lint to
actionlint it, run harden.sh pin-templates --apply if any uses: changed,
then harden.sh scanning --apply.
Add a rule to the Layer 3 ruleset. Edit ruleset_body, then run
harden.sh ruleset --update to see the diff before applying it. Add the
matching assertion to the Layer 3 block in cmd_verify.
Add a subcommand. Add cmd_<name>, register it in the case in main,
add it to usage, and route every side effect through run, put_file, or
create_branch. Add a verify check for whatever it establishes, and update
the subcommand list in README.md.
Rename a domain term. Update CONTEXT.md first, including its _Avoid_
line, then the runbook, the ADRs, and the driver’s output strings.
Record a decision. Add docs/adr/000N-<slug>.md following the existing
Status / Context / Decision / Consequences shape, and link it from the
Decisions list in README.md.
Known gaps and discrepancies
Section titled “Known gaps and discrepancies”These are stated so a reader does not mistake the specification for the implementation.
Layer 1 cannot be verified. cmd_verify reports it as MANUAL. A verify
run that is entirely PASS says nothing about whether SHA-pinning is actually
enforced, and nothing reads back the allowlist that verify prints.
Nothing schedules the reconcile. ADR 0002 calls for an ongoing content
reconcile and cmd_reconcile implements it, but this repository contains no
scheduler. Until an operator wires harden.sh reconcile into cron or a
scheduled workflow, content enforcement runs only when someone runs it.
The presence gate is coarser than its name. file_path_restriction blocks
every push touching .github/dependabot.yml, not just deletion. This is a
GitHub limitation, not an oversight; it is documented in ADR 0002 and mitigated
by the bypass actor, but it does mean a legitimate manual edit is blocked once
the ruleset is Active.
A skipped dcg scan looks like a passing one. dcg-scan.yml skips itself
unless DCG_INSTALL_REF is a full commit SHA, and because it is advisory,
nothing fails when it does. The job reports success either way, and cmd_verify
has no check for the variable. Until an operator sets it, the layer’s second
signal is silently absent. See ADR 0006.
Further reading
Section titled “Further reading”- README.md — orientation and getting started.
- CONTEXT.md — the glossary.
- The runbook — the full specification.
- docs/adr/ — decisions and rationale.
📦 Source: soderlind/gh-hardening · Edit on GitHub