Skip to content

This tool is not affiliated with, endorsed by or sponsored by GitHub, Inc. or Microsoft Corporation. GitHub and GitHub Actions are trademarks of GitHub, Inc. Other names are trademarks of their respective owners.

Branch protection disabled? Auditing GitHub rulesets

Investigate GitHub branch protection or ruleset tampering: protected_branch.destroy, ruleset changes, admin overrides, environments, and what landed meanwhile.

Published on 5 min read

TL;DR. Branch protection and rulesets are what stop one stolen credential from rewriting main. Attackers remove them (protected_branch.destroy, repository_ruleset.destroy), weaken them (protected_branch.update_*, repository_ruleset.update) or bypass them (protected_branch.policy_override). They do the same to deployment environments (environment.remove_protection_rule). The audit log tells you who changed which rule and when. The Git history and Git events tell you what landed while it was off. Restore the rule, then review every push, force-push and deployment in that window.

Code review, required status checks and signed commits are supply-chain controls. They only work while they are enforced. An attacker with admin rights or an owner's token can switch them off for the minutes they need. Some then switch them back on to hide the gap. The audit log keeps both events.

What can be tampered with

ControlRemoved / deletedWeakenedBypassed
Classic branch protectionprotected_branch.destroyprotected_branch.update_* (review count, status checks, force pushes, deletions, admin enforcement…)protected_branch.policy_override
Repository rulesetsrepository_ruleset.destroyrepository_ruleset.updatebypass actors configured in the ruleset
Deployment environmentsenvironment.deleteenvironment.remove_protection_rule, environment.update_protection_rule—

GitHub's documentation on protected branches and rulesets describes each setting. The repository ruleset glossary entry explains how rulesets and classic protection combine.

How the analyzer flags it

RuleActionsSeverity
branch-protection-removedprotected_branch.destroy, repository_ruleset.destroyhigh
branch-protection-overrideprotected_branch.policy_overridemedium
environment-protection-removedenvironment.remove_protection_rule, environment.deletehigh

Findings are grouped by repository. The weakening actions (protected_branch.update_*, repository_ruleset.update) are not rules, because they are part of normal repository administration. They appear in the timeline because every protected_branch. and repository_ruleset. action is kept there. When a finding fires, open the timeline for that repository and read the updates around it.

Reading a removal

A protected_branch.destroy event carries the repository, the branch pattern (name) and the rule's settings at the time, such as required_approving_review_count and admin_enforced. That gives you what was lost. Then answer four questions:

  1. Who and with what? Actor, IP, user agent and hashed_token. A browser session from the owner's office is different from curl/8.5.0 with a token from a VPS.
  2. Was it restored, and by whom? A protected_branch.create for the same branch hours later, by a different actor, usually means someone noticed. The same actor recreating it minutes later suggests a cover-up.
  3. What was pushed in between? Filter the Git events for git.push on that repository in the window, and compare with the branch's commit history. Git events exclude pushes made through the web UI or the API, so check the repository's activity view as well.
  4. Was anything deployed? Workflow runs on the default branch during the window (workflows.created_workflow_run with head_branch: main) may have shipped the change.

Overrides are different

protected_branch.policy_override means a repository administrator pushed or merged while the rule stayed in place, by using their bypass privilege. It is often legitimate: hotfixes, or release managers with a documented exception. It is rated medium for that reason. It becomes interesting when the admin account also shows token or location findings, or when overrides cluster on sensitive paths like .github/workflows/.

Environments: the secrets behind the gate

Environment protection rules (required reviewers, wait timers, deployment branch policies) are often the only thing between a workflow on a random branch and the production cloud credentials stored as environment secrets. Removing a rule, or deleting and recreating the environment, makes those secrets reachable by any workflow run that references the environment. After an environment-protection-removed finding, list the runs that used that environment in the window and treat its secrets as exposed. The Actions secrets article covers how to scope that.

Force-pushes and history rewrites

Rules often block force-pushes. With protection off, an attacker can rewrite history to insert a malicious commit, or to remove evidence of one. The audit log does not record commit contents, but you can still work with:

  • git.push events (with Git events available) for timing and the credential used.
  • protected_branch.update_allow_force_pushes_enforcement_level if the attacker only relaxed that setting.
  • The repository's activity view and your mirrors or CI caches. They may still have the pre-rewrite commits.

Measuring the gap by hand

If you want to check the analyzer's reading, or you only have the raw export, the query is short. Extract every protection and ruleset event for the repository, in time order, with the actor and the IP:

jq -r '.[] | select(.repo=="ORG/REPO")
  | select(.action|test("^(protected_branch|repository_ruleset|environment)\\."))
  | [(.["@timestamp"]/1000|todate), .action, .actor, (.actor_ip // "-"), (.name // "-")]
  | @tsv' audit-log.json | sort

Read the output as pairs. Every destroy or update that weakens a rule should be followed by a create or update that restores it. The time between the two is your exposure window. Write it down in UTC, because everything else in the scoping (pushes, workflow runs, deployments) is filtered on that window. If there is no matching restore, the window is still open. Fix that before you continue the investigation. Also check whether the weakening and the restore came from the same account. When one person switches protection off and back on around a single push, you need to talk to that person, whatever the reason turns out to be.

Remediation checklist

  • Recreate branch protection and rulesets from a known-good definition (keep rulesets in code, or export them).
  • Restore environment protection rules and rotate the environment's secrets.
  • Review every commit, merge, force-push and deployment made during the gap, and revert or redeploy from a trusted commit.
  • Remove unexpected bypass actors from rulesets and unexpected repository admins.
  • For the controls that matter most, prefer organization-level rulesets. They are managed at the organization level, not by each repository's admins.

Related articles

GitHub organization takeover indicators in the audit log: new owners, 2FA requirement off, SAML SSO changed, IP allow list off, audit streaming removed.
What to do when your GitHub organization is compromised: preserve the audit log, contain tokens and workflows, scope with Git events, then pivot to the cloud.
What the GitHub audit log does not record: 180-day and 7-day retention, plan limits for API and Git events, hidden IPs, no file contents or secret reads.

This tool is not affiliated with, endorsed by or sponsored by GitHub, Inc. or Microsoft Corporation. GitHub and GitHub Actions are trademarks of GitHub, Inc. Other names are trademarks of their respective owners.