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.
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
| Control | Removed / deleted | Weakened | Bypassed |
|---|---|---|---|
| Classic branch protection | protected_branch.destroy | protected_branch.update_* (review count, status checks, force pushes, deletions, admin enforcement…) | protected_branch.policy_override |
| Repository rulesets | repository_ruleset.destroy | repository_ruleset.update | bypass actors configured in the ruleset |
| Deployment environments | environment.delete | environment.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
| Rule | Actions | Severity |
|---|---|---|
branch-protection-removed | protected_branch.destroy, repository_ruleset.destroy | high |
branch-protection-override | protected_branch.policy_override | medium |
environment-protection-removed | environment.remove_protection_rule, environment.delete | high |
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:
- Who and with what? Actor, IP, user agent and
hashed_token. A browser session from the owner's office is different fromcurl/8.5.0with a token from a VPS. - Was it restored, and by whom? A
protected_branch.createfor the same branch hours later, by a different actor, usually means someone noticed. The same actor recreating it minutes later suggests a cover-up. - What was pushed in between? Filter the Git events for
git.pushon 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. - Was anything deployed? Workflow runs on the default branch during the window (
workflows.created_workflow_runwithhead_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.pushevents (with Git events available) for timing and the credential used.protected_branch.update_allow_force_pushes_enforcement_levelif 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
- GitHub org takeover: owners, 2FA, SSO and IP allow list
- GitHub Actions secrets leak: investigating exfiltration
- The fictional northwind-labs walkthrough: branch protection removed at 03:05 UTC, restored at 07:48
- MITRE ATT&CK T1562 Impair Defenses