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
Six routes, three levels of visibilityCollaborators and teams appear on the settings page everyone checks. Apps and deploy keys require separate API calls. Bypass entries and administrative override are the least visible and the most consequential, because they skip the rules rather than operating within them.VisiblecollaboratorsteamsNeeds a queryinstalled appsdeploy keysSkips the rulesbypass entriesadmin overridean audit that stops at the first column answers a question nobody asked

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
Capability against use, on a five-year-old repositoryFar more identities can push than do. The difference is people who moved teams, automation for systems that were retired, and access granted for a migration that finished. None of it is malicious and all of it is available to an attacker who compromises any one of those accounts.identities with push access to maincan push63pushed in 6 months11pushed in 24 months19the difference between the first and last bar is the audit's output

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
An audit that produces action rather than a listEnumerate every route, expand teams to individuals, compare capability against actual use, investigate bypasses specifically, and end with named removals. The last step is what distinguishes an audit from an inventory.Six routesnot just settingsExpand teamsto individualsCompare with usecapability vs actualNamed removalsspecific, actionablean audit whose output is a number has not reduced anything

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.