Skip to content

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.

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 .docx and .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. reconcile is the mechanism ADR 0002 calls for, but nothing here runs it on a timer; that is the operator’s cron or scheduled workflow.

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.

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

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

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.

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.

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.

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.

scripts/harden.sh has five internal tiers. Calls go downward only; nothing in a lower tier calls a cmd_* function.

  1. Entry pointmain parses the subcommand and flags, loads configuration, runs preflight, and dispatches.
  2. Commandscmd_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.
  3. Desired-state buildersruleset_body, generate_zizmor_config, dependabot_config, stage_layer2. Pure: they emit the intended artifact and touch no network.
  4. GitHub helperslist_orgs, list_repos, repo_exists, default_branch, remote_file_sha, remote_file_matches, put_file, create_branch, find_ruleset_id.
  5. 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).

Nothing mutates unless --apply set APPLY=1. That check appears in exactly three places:

  • run — the wrapper for direct gh calls; without --apply it prints a printf '%q ' quoting of the command.
  • put_file — checks APPLY itself, because the request body has to be base64-encoded and the blob sha looked 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.

Trigger: scripts/harden.sh scanning --apply

  1. main sets APPLY=1, calls load_conf then apply_env_overrides, then require_tools and load_token_scopes.
  2. cmd_scanning calls need_platform_org and need_namespaces to validate the slugs, then require_scopes repo workflow.
  3. stage_layer2 copies templates/security-workflows/ into a temp directory and writes generate_zizmor_config output to zizmor.yml beside it. zizmor.yml exists only here — there is no such file in the repository.
  4. repo_exists decides the mode. Absent: create the repository with visibility=internal and auto_init=true, then seed every file directly to the default branch. Present: resolve the default branch via default_branch, and route changes through a timestamped chore/security-workflows-sync-* branch.
  5. For each staged file, remote_file_matches base64-decodes the remote copy and cmps it against the local one. Identical files are skipped, so a no-op run opens no pull request.
  6. On the first differing file, create_branch creates the ref from the base head SHA. Then put_file writes each file, supplying the existing blob sha only when updating.
  7. If anything changed and the repository already existed, gh pr create opens 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.

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.

  1. A developer opens a pull request in any repository in the enterprise.
  2. The Layer 3 enterprise ruleset — created by cmd_ruleset, targeting ~ALL organizations and ~DEFAULT_BRANCH — injects the required workflow by repository ID and path, at the ref cmd_ruleset resolved from security-workflows’ own default branch.
  3. zizmor-scan.yml runs zizmor against the pull request and uploads SARIF to Code Scanning. Its unpinned-uses policy comes from the generated zizmor.yml.
  4. dcg-scan.yml runs from each repository’s own workflow directory, diffs against the base ref and runs dcg scan --fail-on none, uploading SARIF. It is advisory: it is not in the ruleset and cannot block the merge. It skips itself entirely unless DCG_INSTALL_REF is a full commit SHA. See ADR 0006.
  5. 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.
  6. In Evaluate mode the ruleset records violations in Insights and the merge proceeds. After cmd_ruleset_activate, it blocks the merge.

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.

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.

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.

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.

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.

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.

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.