Auditing who can push to protected branches Jump to heading
“Who can push to main?” sounds like a settings question and is really six of them. Collaborators and teams are the visible answer; installed apps, deploy keys, ruleset bypass entries and administrative override are the ones that make the visible answer wrong. An audit that enumerates only the first two produces a confident list that omits the routes an attacker would prefer. This recipe covers all six and compares them against actual use, within repository access control and policy.
When to use this approach Jump to heading
- An audit or review has asked the question and you want a defensible answer.
- Access has accumulated over years and nobody has looked.
- A push landed that should not have been possible.
- Before or after tightening rulesets, to see what changed.
- If the repository is two months old with three collaborators, the settings page is the answer.
Step 1 — Enumerate the six routes Jump to heading
# 1. Direct collaborators with push access
gh api repos/:owner/:repo/collaborators --paginate \
--jq '.[] | select(.permissions.push) | "user\t\(.login)"' > /tmp/access.tsv # 2. Teams
gh api repos/:owner/:repo/teams --paginate \
--jq '.[] | select(.permission == "push" or .permission == "admin") | "team\t\(.slug)"' >> /tmp/access.tsv
# 3. Installed apps with write permission
gh api repos/:owner/:repo/installation --jq '"app\t\(.app_slug)\t\(.permissions.contents // "none")"' >> /tmp/access.tsv 2>/dev/null
# 4. Writable deploy keys
gh api repos/:owner/:repo/keys --jq '.[] | select(.read_only | not) | "key\t\(.title)"' >> /tmp/access.tsv # 5. Ruleset bypass entries
gh api repos/:owner/:repo/rulesets --jq '.[] | .bypass_actors[]? | "bypass\t\(.actor_type)/\(.actor_id)"' >> /tmp/access.tsv
# 6. Administrators, who can change any of the above
gh api repos/:owner/:repo/collaborators --paginate \
--jq '.[] | select(.permissions.admin) | "admin\t\(.login)"' >> /tmp/access.tsv # Verification: the full list, by route
sort /tmp/access.tsv | awk -F'\t' '{print $1}' | uniq -c Step 2 — Expand teams to people Jump to heading
A team with push access is a list of people, and that list changes without touching repository settings.
awk -F'\t' '$1=="team" {print $2}' /tmp/access.tsv | while read -r t; do
gh api "orgs/acme/teams/$t/members" --paginate --jq ".[] | \"via-team:$t\t\(.login)\""
done >> /tmp/expanded.tsv # The full human list, deduplicated
{ awk -F'\t' '$1=="user" {print $2}' /tmp/access.tsv
awk -F'\t' '{print $2}' /tmp/expanded.tsv; } | sort -u > /tmp/humans.txt
wc -l /tmp/humans.txt # Verification: everyone on the list should be currently employed and currently relevant Step 3 — Compare against who has actually pushed Jump to heading
The gap between capability and use is where the audit finds things.
# Who has pushed to the default branch in the last six months?
git log --format='%cn <%ce>' origin/main --since='6 months ago' | sort -u > /tmp/actual.txt
wc -l /tmp/actual.txt # Capability without use
comm -23 /tmp/humans.txt <(sed 's/ <.*//' /tmp/actual.txt | sort -u) | head -20 # Verification: each name in that list is either still needed or removable Step 4 — Investigate the bypass entries specifically Jump to heading
These are the entries most likely to be forgotten and most consequential when they are.
gh api repos/:owner/:repo/rulesets --jq '.[] | {ruleset: .name, bypass: [.bypass_actors[]? | {actor_type, actor_id, bypass_mode}]}' # Has any of them actually been used?
gh api "repos/:owner/:repo/rulesets/$ID/history" --jq '.[] | "\(.created_at)\t\(.actor.login)\t\(.state)"' | head # Verification: every bypass entry has a documented reason and an expiry
grep -c '^## ' access-exceptions.md SAFETY WARNING — a bypass entry lets its holder push in violation of every rule the ruleset expresses, with no distinguishing mark on the resulting commit. An audit that lists it as “one bypass actor” understates it: that entry is equivalent to the branch being unprotected for that identity. Treat each one as a finding requiring justification rather than as configuration to note.
Step 5 — Produce a report someone can act on Jump to heading
An audit that ends in a spreadsheet nobody reads has not reduced any risk.
{
echo "# Push access to acme/app:main — $(date -u +%F)"
echo
echo "## Summary"
printf -- "- humans with push access: %s\n" "$(wc -l < /tmp/humans.txt)"
printf -- "- of whom pushed in 6 months: %s\n" "$(comm -12 /tmp/humans.txt <(sed 's/ <.*//' /tmp/actual.txt | sort -u) | wc -l)"
printf -- "- writable deploy keys: %s\n" "$(awk -F'\t' '$1=="key"' /tmp/access.tsv | wc -l)"
printf -- "- ruleset bypass entries: %s\n" "$(awk -F'\t' '$1=="bypass"' /tmp/access.tsv | wc -l)"
echo
echo "## Recommended removals"
comm -23 /tmp/humans.txt <(sed 's/ <.*//' /tmp/actual.txt | sort -u) | sed 's/^/- /'
} > /tmp/access-report.md
head -20 /tmp/access-report.md # Verification: the report names specific removals, not general concerns
grep -c '^- ' /tmp/access-report.md Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
How far back should the usage comparison look? Jump to heading
Six months for a routine review and twenty-four for a first audit, because the longer window catches seasonal automation — a release process that runs annually, a migration tool used once a year. Anything unused across twenty-four months is removable with very little risk of surprise.
Does removing access break things silently? Jump to heading
Occasionally, and the failure is loud rather than silent: a pipeline fails, someone reports it, and the credential is recreated in a minute. That asymmetry — reversible removal against permanent accumulation — is the argument for removing first and reinstating on demand rather than the reverse.
What about access granted at the organisation level? Jump to heading
Organisation owners and organisation-wide teams grant access that repository settings do not show, which is why the enumeration should be run at both levels for a complete picture. An organisation owner can reach every repository regardless of what any repository’s settings say.
Related Jump to heading
- Repository Access Control & Policy — the parent topic and the drift this audit catches.
- Scoping Deploy Keys and Tokens — narrowing the automation the audit finds.
- Offboarding a Developer From Every Repository — the event that should trigger a targeted version of this.