Die Oberfläche ist lokalisiert; Skill-Beschreibungen und -Dokumente werden per KI automatisch in Ihre Sprache übersetzt. Code, Befehle, Pfade und Fachbegriffe bleiben im Original, maßgeblich ist das Dokument des Autors.
Skills entdecken
Entwicklung · SKILL.md

harden-github-repo-security

harden-github-repo-security

Apply GitHub repository security protections tier by tier — rulesets, read-only Actions token, secret scanning + push protection, Dependabot, and (gated) required status checks / required PR with a GitHub App bypass for a CI auto-commit bot. Mutating and confirmation-gated: always assess first, apply the no-regret baseline, then decide required checks separ…

Independent GitHub snapshot · Automated classification · Original documentation governs

Skill-Dokumentation

GitHub

name: harden-github-repo-security description: > Apply GitHub repository security protections tier by tier — rulesets, read-only Actions token, secret scanning + push protection, Dependabot, and (gated) required status checks / required PR with a GitHub App bypass for a CI auto-commit bot. Mutating and confirmation-gated: always assess first, apply the no-regret baseline, then decide required checks separately. Use when hardening a public user-owned repo after an audit, when a repo has no branch protection, when adding required checks without breaking a bot that pushes to the default branch, or when provisioning a GitHub App bypass actor for trusted automation. license: MIT allowed-tools: Read Write Edit Bash metadata: author: Philipp Thoss version: "1.0" domain: git complexity: advanced language: multi tags: github, security, rulesets, branch-protection, github-actions, hardening

Harden GitHub Repository Security

Apply GitHub protections in order of blast radius: assess, then a no-regret baseline that breaks no CI, then a gated decision on required checks / PR. No-regret means every item is either a control you will not want to undo or a decision you will not want to have skipped — not that the tier is decision-free. Every mutating step is confirmation-gated. Running example: a public, user-owned repo whose CI auto-commits to the default branch via stefanzweifel/git-auto-commit-action with the default GITHUB_TOKEN.

Throughout, R = OWNER/REPO (e.g. pjt222/agent-almanac). Set it once: R=OWNER/REPO. All gh api writes require repo admin.

When to Use

  • Hardening a public user-owned repo after assess-github-repo-security
  • A repo has no ruleset / branch protection and you want a safe baseline
  • Adding required status checks or required PR without breaking a bot that pushes to the default branch
  • Provisioning a GitHub App (or deploy key) bypass actor for trusted automation

Inputs

  • Required: OWNER/REPO and admin access (gh auth status shows admin)
  • Required: whether a CI bot pushes to the default branch (and which action/identity)
  • Optional: assessment report from assess-github-repo-security
  • Optional: exact CI check context name(s) to require (must run on push)
  • Optional: GitHub App id + private key if going to required checks/PR

Procedure

Step 1: Assess First and Confirm Scope

Never mutate blind. Run the read-only assessment and confirm the plan with the user.

# 1. Run the companion read-only skill first (or its core probes):
gh api /repos/$R --jq '{visibility,default_branch,is_org:.organization!=null}'
gh api /repos/$R/rulesets --jq '.[] | {id,name,enforcement}'
gh api /repos/$R/actions/permissions/workflow

# 2. Confirm the two decisive facts before any write:
#    - Is the repo PUBLIC and USER-OWNED?  (rulesets are free here)
#    - Does a bot push to the default branch?  (gates Step 3 entirely)

Confirm with the user: baseline (Step 2) breaks no CI, but it is not decision-free — its fork-PR approval item is a deliberate choice, not a default to apply blind. Step 3 (required checks / PR) is a separate opt-in that will block a default-branch bot unless a bypass is provisioned first.

Expected: You know visibility, owner type, current rulesets, current token default, and whether a bot pushes to the default branch. The user has approved applying at least the baseline.

On failure: If the repo is private and user-owned, rulesets need GitHub Pro — stop and surface that. If not admin, stop (writes will 403). If a bot pushes to the default branch, flag that Step 3 is blocked until Step 3a runs.

Step 2: Apply the No-Regret Baseline (breaks no CI auto-commit)

This tier hardens the repo without breaking a direct-push bot. Apply after confirmation. Force-push/deletion protection, a read-only token default, the free security features, and policy files.

# 2a. Default-branch ruleset: force-push + deletion protection ONLY.
#     These do NOT block the bot's direct push.
gh api --method POST /repos/$R/rulesets --input - <<'JSON'
{
  "name": "protect-default",
  "target": "branch",
  "enforcement": "active",
  "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
  "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" } ]
}
JSON

# 2b. Actions token: read-only default + no token PR approval.
#     A DEFAULT (not a cap) for same-repo push events, so the bot still works
#     once its job declares contents: write.
gh api --method PUT /repos/$R/actions/permissions/workflow \
  -f default_workflow_permissions=read \
  -F can_approve_pull_request_reviews=false

# 2c. Free security features (all free on public repos):
gh api -X PUT repos/$R/vulnerability-alerts          # alerts + dependency graph
gh api -X PUT repos/$R/automated-security-fixes      # Dependabot fix PRs (separate toggle!)
gh api -X PATCH repos/$R --input - <<'JSON'
{"security_and_analysis":{"secret_scanning":{"status":"enabled"},"secret_scanning_push_protection":{"status":"enabled"}}}
JSON
gh api -X PUT repos/$R/private-vulnerability-reporting

# 2d. Fork-PR approval — READ it here; the value is a decision, not a default to
#     apply blind. See the paragraph below before writing.
gh api repos/$R/actions/permissions/fork-pr-contributor-approval
# Write, once decided (loosest to strictest):
#   first_time_contributors_new_to_github | first_time_contributors | all_external_contributors
# gh api -X PUT repos/$R/actions/permissions/fork-pr-contributor-approval \
#   -f approval_policy=first_time_contributors_new_to_github

Decide fork-PR approval deliberately. It is the one item in this tier that is a decision rather than a control, which is why the tier is no-regret and not zero-downside — the term this skill used until #768, retired in #707 because a tier containing a decision is not downside-free. The public-repo default is "Require approval for first-time contributors", and fork pull_request runs already receive a read-only GITHUB_TOKEN with no access to secrets — so the default is safe. What it also does is run nothing at all on a first-time contributor's PR until a maintainer clicks approve, and if nobody notices, the contributor sees a PR with no checks and no signal. Measured on this repository's first external PR: 0 workflow runs, 0 check-runs, 0 check-suites.

The setting is readable and writable over the API — it is not a UI-only toggle, and believing otherwise sends people designing around a constraint that does not exist.

The predicate is fork-REACHABILITY, not "does this repo have secrets." That test is wrong in both directions: a repo whose deploy runs only on push-to-main is not endangered by loosening this, because a fork PR cannot trigger it and the platform withholds secrets from fork runs regardless; a repo whose pull_request validators run on self-hosted runners should stay strict with no secret anywhere, because arbitrary code execution on your own hardware, resource abuse and cache-poisoning are what the gate is actually for — and none of them is "touches secrets". Measure reachability with the four commands in guides/protecting-github-repositories.md under "Decide fork-PR approval deliberately", and read the ruler warnings beside them rather than reaching for the obvious grep — an unanchored pull_request scan matches pull_request_target, the one trigger that runs base-repo code with secrets, and misses on: [push, pull_request] entirely. Both errors read as safe.

Then add two tracked files (commit them):

.github/dependabot.yml — include a github-actions block so action SHAs stay fresh:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

.github/SECURITY.md — a short disclosure policy pointing reporters at the "Report a vulnerability" button (private vulnerability reporting, enabled above).

Flipping the token default to read silently strips write from every workflow that assumed a write default, not just the auto-commit job. Audit all files in .github/workflows/ and declare the minimal permissions: each job actually needs — common scopes are contents, packages, pull-requests, pages, and id-token. For the running example: top-level permissions: {}, contents: write only on the auto-commit job, and every uses: pinned to a full 40-char commit SHA with the version in a trailing comment.

Expected: gh api /repos/$R/rulesets shows protect-default active; .../actions/permissions/workflow shows default_workflow_permissions: read and can_approve_pull_request_reviews: false; secret scanning + push protection

  • Dependabot + private reporting are on; .github/dependabot.yml and .github/SECURITY.md are committed. The bot's next run still pushes. .../actions/permissions/fork-pr-contributor-approval has been read, the guide's four reachability commands have been run, and their results are quoted in the same turn as the decision — one line suffices, e.g. 1: 12 pull_request workflows · 2: none · 3: 2 secret-bearing · 4: none. The policy is then left or changed, and both branches require that quote: tightening on instinct is the same defect as leaving an unexamined default, and the quote is what distinguishes either from a decision. Quote the output rather than asserting the measurement — every other criterion in this step is checkable against live API state, and this one is checkable only against what you paste.

On failure: If 2b makes an existing bot push 403 — or any other workflow fails after losing a write scope it silently relied on — the job is missing the explicit permissions: it needs (e.g. contents: write, packages: write, pull-requests: write); add the minimal scope back (the read-only default is not a hard cap for same-repo pushes). If automated-security-fixes seems to do nothing, verify vulnerability-alerts (2c line 1) ran first — alerts are a separate prerequisite toggle from fixes. If push protection later fails a bot run, treat the flagged secret as a real finding — the bot cannot click the interactive web bypass.

Step 3: Decision Gate — Required Status Checks / Required PR

STOP. Required checks and required PR block direct pushes to the ref, not just PR merges. If a bot pushes to the default branch, turning either on will break it unless you first provision a bypass. The default GITHUB_TOKEN / github-actions[bot] cannot be a bypass actor (GitHub design). Do not enable this tier on a bot-pushing branch without a bypass in place.

Also, on a solo repo, required_approving_review_count >= 1 is a self-lockout (you cannot approve your own PR) — keep it 0. And a required check that only runs on pull_request never reports on a direct push, so it permanently blocks the bot. Ensure the check runs on push.

Step 3a — provision the bot bypass FIRST (if a bot pushes to this branch). Recommended: a GitHub App installation token. (Deploy key is a narrower single-repo alternative: add an SSH deploy key with write access and use actor_type: DeployKey in 3b.)

Create a GitHub App (personal account, Settings > Developer settings > GitHub Apps), permission Repository > Contents: Read and write; install it on THIS repo. Store the App id as a repo variable and the private key as an Actions secret. In the workflow, mint a token and pass it to checkout + the commit action:

  - uses: actions/create-github-app-token@<sha>   # v2
    id: app-token
    with: { app-id: ${{ vars.APP_ID }}, private-key: ${{ secrets.APP_KEY }} }
  - uses: actions/checkout@<sha>
    with: { token: ${{ steps.app-token.outputs.token }} }
  - uses: stefanzweifel/git-auto-commit-action@<sha>
    with: { ... }   # inherits the app token via the checkout credentials

The numeric App id used in Step 3b is shown on the App's own page: Settings > Developer settings > GitHub Apps > <your app> > About ("App ID"). It is unrelated to the repository id and cannot be derived from /repos/$R.

Deploy-key alternative (no App to maintain). Register a write deploy key and push over SSH; the checkout's ssh-key makes git-auto-commit-action push as that identity:

# ssh-keygen -t ed25519 -N '' -f deploy_key   # then register the public half:
gh api repos/$R/keys -f title="ci-bot" -f key="$(cat deploy_key.pub)" -F read_only=false
gh secret set DEPLOY_KEY < deploy_key   # store the private half, then delete the local copy
#   - uses: actions/checkout@<sha>
#     with: { ssh-key: ${{ secrets.DEPLOY_KEY }} }        # remote becomes SSH via the key
#   - uses: stefanzweifel/git-auto-commit-action@<sha>    # inherits the SSH remote
# In 3b use actor_type "DeployKey"; GitHub stores its actor_id as null (it matches
# ANY write deploy key on the repo), so keep the deploy-key list minimal.

Higher-assurance alternative (no standing bypass actor). If a manual merge click is acceptable, convert the job to open a PR with the App token (e.g. peter-evans/create-pull-request) and let a human merge. No bypass actor is needed and every rule stays enforced even against the automation's identity. The App token is still required: a PR opened with the plain GITHUB_TOKEN does not trigger pull_request/push workflows, so required checks never run and it sits expected forever (see Common Pitfalls).

Step 3b — apply the hardened ruleset with the App as an Integration bypass actor. Replace RULESET_ID with the id from Step 2a, <APP_ID> with your App id, and build with your real check context. bypass_mode: always because the action pushes directly (use pull_request only if the bot opens PRs).

gh api --method PUT /repos/$R/rulesets/RULESET_ID --input - <<'JSON'
{
  "name": "protect-default",
  "target": "branch",
  "enforcement": "active",
  "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
  "bypass_actors": [ { "actor_id": <APP_ID>, "actor_type": "Integration", "bypass_mode": "always" } ],
  "rules": [
    { "type": "deletion" },
    { "type": "non_fast_forward" },
    { "type": "pull_request", "parameters": { "required_approving_review_count": 0, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": false, "allowed_merge_methods": ["merge"] } },
    { "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": true, "do_not_enforce_on_create": true, "required_status_checks": [ { "context": "build" } ] } }
  ]
}
JSON

# Verify the bypass actor is present:
gh api /repos/$R/rulesets/RULESET_ID --jq '.bypass_actors'

do_not_enforce_on_create: true avoids the new-branch catch-22 (CI can't have run on a branch that does not exist yet). If there is no bot on this branch, you may skip 3a and omit bypass_actors.

Bypass is whole-ruleset, not per-rule. The single ruleset above lets the bypass actor skip deletion/non_fast_forward too — a bypass actor could force-push or delete the branch. To keep ref protection universal while exempting only the check, split into two stacked rulesets (they aggregate, most-restrictive-wins): ruleset A = deletion + non_fast_forward with bypass_actors: [] (applies to everyone, including you), ruleset B = required_status_checks with the bot — and, if you want to keep your own direct-push, your own { "actor_type": "User", "actor_id": <your-user-id>, "bypass_mode": "always" } — in bypass_actors. A successful bypassed push prints remote: Bypassed rule violations … Required status check "<name>" is expected.

Trade-off: strict_required_status_checks_policy: true ("branch must be up to date") forces every open human PR to be re-updated each time the bot auto-commits to the default branch, and dismiss_stale_reviews_on_push: true compounds the churn — a chatty bot can make PRs perpetually unmergeable. On a frequently-auto-committed branch prefer strict_required_status_checks_policy: false (loose) unless up-to-date-before-merge is genuinely required.

Expected: The ruleset now enforces the required check and required-PR rule for humans and forks, .bypass_actors lists the App (Integration), and the bot's next run still lands on the default branch (via the App token). Humans must open a PR; the check must be green.

On failure: If the bot 403s after 3b, the App identity is not actually the bypass actor — confirm the workflow passes the App token to checkout (not just to the commit action) and that <APP_ID> in bypass_actors matches. If the bot's push sits expected/pending forever, the required check does not run on push — make it trigger on push or drop it from the required list. If the bypass-actor web picker won't add the actor on a personal repo, the REST API still works — add it via gh api --method PUT .../rulesets/ID with a bypass_actors entry (Integration/DeployKey/User/RepositoryRole), confirmed on free personal public repos; the org move is a last resort, not the fix. If merges are blocked on a solo repo, required_approving_review_count is >= 1 — set it to 0.

Step 4: Optional Advanced Hardening

Apply individually, each still confirmation-gated. None of these are needed for the baseline; several have sharp edges.

# 4a. Tag ruleset — protect release tags (classic tag protection is deprecated):
gh api --method POST /repos/$R/rulesets --input - <<'JSON'
{ "name": "protect-tags", "target": "tag", "enforcement": "active",
  "conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } },
  "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" } ] }
JSON

# 4b. Merge-method toggles (e.g. merge-commits-only to preserve per-commit history):
gh api -X PATCH repos/$R -F allow_merge_commit=true -F allow_squash_merge=false \
  -F allow_rebase_merge=false -F delete_branch_on_merge=true

# 4c. CodeQL "default setup" (server-managed — commits NO workflow YAML):
gh api -X PATCH repos/$R/code-scanning/default-setup -f state=configured -f query_suite=default

# 4d. sha_pinning_required — ONLY after every `uses:` is a full 40-char SHA,
#     or every workflow run fails (including actions/checkout):
gh api --method PUT /repos/$R/actions/permissions -F enabled=true \
  -f allowed_actions=selected -F sha_pinning_required=true
# selected mode also gates WHICH actions may run: github_owned covers actions/*,
# but git-auto-commit-action is NOT GitHub-owned — allow-list it in the SAME
# step or the bot fails with "actions are not allowed":
gh api --method PUT /repos/$R/actions/permissions/selected-actions \
  -F github_owned_allowed=true -F verified_allowed=false \
  -f 'patterns_allowed[]=stefanzweifel/git-auto-commit-action@*'
gh api /repos/$R/actions/permissions --jq '.sha_pinning_required'   # verify

Note: allowed_actions=selected with github_owned_allowed=true still blocks stefanzweifel/git-auto-commit-action until it is in patterns_allowed (the 4d allow-list command handles this). Require-signed-commits and require-linear-history are intentionally omitted here: signing blocks the unsigned git-CLI bot push, and linear history is mutually exclusive with a merge-commits-only policy.

Expected: Each selected advanced control is applied and verified without breaking the bot: tag ruleset lists on .../rulesets, merge toggles reflect on the repo object, CodeQL default setup shows state: configured, sha_pinning_required reads true only after all actions are SHA-pinned, and git-auto-commit-action is in patterns_allowed so the bot still runs.

On failure: If every workflow fails right after 4d, an action is still tag-pinned — convert all uses: to SHAs or revert sha_pinning_required to false. If the bot fails with "actions are not allowed" after restricting allowed actions, add git-auto-commit-action to patterns_allowed. If 4c errors, the repo may have no CodeQL-supported language — skip it.

Validation

  • Assessment ran and scope (public/user-owned, bot-on-default-branch) confirmed before any write
  • protect-default ruleset active with deletion + non_fast_forward
  • default_workflow_permissions=read and can_approve_pull_request_reviews=false
  • Secret scanning + push protection + Dependabot alerts + fixes + private reporting enabled
  • .github/dependabot.yml (github-actions ecosystem) and .github/SECURITY.md committed
  • Fork-PR approval policy read, the four reachability commands run, and their four results quoted alongside the chosen value — for a change and for a non-change alike. An unquoted "I measured it" is the one claim in this checklist nothing else can corroborate
  • Auto-commit bot's next run still pushes successfully (baseline did not break it)
  • Required checks/PR enabled ONLY with a GitHub App (or deploy key) bypass in place first
  • Solo repo keeps required_approving_review_count: 0; required checks run on push
  • Loop guard added if a bot now pushes with an App token or PAT

Common Pitfalls

  • "Fork-PR approval is a Settings-only toggle." It is at /repos/{owner}/{repo}/actions/permissions/fork-pr-contributor-approval, readable and writable, with three values. Designing a manual procedure around the belief that it is web-UI-only is wasted work — and this skill said so itself until #768. Verify the premise before designing around it.

  • GITHUB_TOKEN is not a bypass actor: github-actions[bot] / the default token cannot be added to any ruleset bypass list and cannot push to a protected branch, by design. "Just add the Actions bot to the bypass list" has no such option — the push 403s. Use a GitHub App or deploy key.

  • PAT bypass is an anti-pattern: an admin/personal PAT in the bypass list (or classic BP with enforce_admins=false) exempts ALL admin actions including your own interactive pushes — role-wide, not automation-scoped, and long-lived. Prefer an App (Integration) or deploy key (DeployKey).

  • Required checks only on pull_request: a check that never runs on push never reports on the bot's direct push, so it permanently blocks the bot and new-branch creation. Make the check run on push and set do_not_enforce_on_create: true.

  • Loop guard once an App token replaces GITHUB_TOKEN: the default token suppresses downstream push/pull_request triggers; an App token or PAT does not, so the auto-commit workflow can re-trigger itself indefinitely. Guard with if: github.actor != '<app>[bot]', paths-ignore, or [skip ci].

  • PR via the default GITHUB_TOKEN is a false fix: a PR opened by GITHUB_TOKEN does not trigger pull_request/push workflows, so required checks never run and it sits expected forever. Create the PR with the App token.

  • Alerts != fixes: vulnerability-alerts and automated-security-fixes are separate toggles; enabling alerts opens no PRs. Enable both.

  • Don't buy GHAS on a public repo: secret scanning, push protection, CodeQL, and dependency review are already free on public repos; the paid Secret Protection / Code Security products target private/org repos.

Related Skills

  • assess-github-repo-security - the read-only companion; always run this first
  • configure-git-repository - baseline .gitignore, branches, remotes, hooks
  • setup-github-actions-ci - the CI workflow whose token/permissions this hardens
Gespeicherte Skills