Diagnostic GitHub API
branch protection is unreadable without admin, not absent
The compliance report says nought out of two hundred and twelve repositories have branch protection. The security lead opens three of them at random and every one has protection on main, with required reviews and required checks, exactly as the policy says. The script is not lying about what it saw. It asked for the protection rules with a token that has read access and no more, GitHub answered 403 Must have admin rights to Repository., and somewhere in a try block that refusal became protected = False.
The detailed protection rules on a branch are readable only with admin on the repository. GET /repos/{owner}/{repo}/branches/{branch}/protection has three distinct outcomes and they must never be collapsed into two: 200 with a protection object means protected and readable, 404 with {"message":"Branch not protected"} means genuinely unprotected, and 403 with the admin-rights message means you cannot see. A 403 is not an absence.
Two readings are available without admin and are the honest fallback. GET /repos/{owner}/{repo}/branches/{branch} returns a protected boolean, which is enough for coverage reporting. GET /repos/{owner}/{repo}/rules/branches/{branch} returns the rules a ruleset contributes to that branch, with no admin required at all. Report coverage from those, keep “unknown” as its own column, and grant repository admin only where the detailed rules genuinely have to be audited.
The problem in plain words
The report is worse than useless because it is confident. A coverage number that says zero per cent reads as an emergency, so somebody spends a morning proving it wrong one repository at a time in the web interface, and the conclusion at the end of that morning is “the script is broken” rather than anything about the estate. The next version of the script is written to be less strict, which is the wrong lesson entirely.
The reason the refusal turns into an absence is that the two failures look alike from inside an exception handler. Both are non-200. Both have a JSON body with a message. Both happen on the same URL, on repositories that otherwise read perfectly, and the code that catches them is usually one except or one if not response.ok with a single fallback value underneath it, written on the reasonable-sounding grounds that a missing protection object means there is no protection. That is true of exactly one of the two failures.
It also fails asymmetrically, which is how it survives review. An auditor run by a repository administrator on their own repositories works perfectly, because that token gets 200s. The same script scheduled with an organisation-wide read-only token gets 403s everywhere, and the difference between the two runs is invisible in the output: both produce a clean list of repositories with a boolean next to each one, and only one of them is measuring anything.
The mirror-image failure is quieter and more dangerous. A script that reports the estate as fully protected because it was run by an owner is not going to notice the repository where protection was removed on a Friday, because the finding it was built to detect is the one it never gets to see.
Why it happens
Three outcomes, not two. The protection endpoint is documented as requiring admin rights, so its refusal is a normal answer rather than an error condition. The states have different meanings and different repairs: 200 is a measurement, 404 Branch not protected is a finding, and 403 is a gap in your instrument. Compressing three into two is where the whole failure lives, and it is one line of code.
The protected boolean is visible to anyone who can read the branch. GET /repos/{owner}/{repo}/branches/{branch} carries protected: true|false, and it costs the same request whether or not the rules are readable. For the question a compliance sweep is actually asking — is this branch protected at all — that field is the answer and admin is not needed. What it cannot tell you is how: two approving reviews or one, which status checks, whether administrators are included.
Rulesets are readable without admin, and they are a second source of protection. GET /repos/{owner}/{repo}/rules/branches/{branch} lists the rules that apply to a branch from repository and organisation rulesets, including the ruleset each one came from. A branch can be governed entirely by a ruleset, and an audit that only looks at the classic protection endpoint will describe such a branch in terms of what it could not read rather than in terms of the rules that are plainly published to it.
A push is refused by the rule, and the rule is readable. The refusal a developer sees is GH006: Protected branch update failed or protected branch hook declined, arriving from Git rather than from the API. There is no need to send a commit to find out which rule declined it: required reviews refuse a direct push outright, required status checks refuse a merge until the named contexts pass, restrictions names the only actors allowed to push, and enforce_admins decides whether an administrator is exempt. Every one of those is a field, and every one of them can be read, printed and explained without touching the branch.
This is not the multi-cause 404. A 404 on the repository means several things at once and needs a credential triage. The 403 here is unusually informative: it names admin rights, it arrives on a repository the same token just read successfully, and the fix is a grant on one repository rather than a new token. Where the protection endpoint answers 404 without the “Branch not protected” message, that is the other note and the script says so rather than guessing.
Granting admin to an auditor is a real cost, so be sure you need it. Repository admin carries settings, collaborators, deploy keys and deletion along with the ability to read protection, which is a large grant to hand a scheduled job for the sake of one field. A GitHub App with administration: read is the narrow version of the same thing and is the answer where the detailed rules are genuinely required. Where they are not, the boolean and the ruleset listing cost nothing and expose nothing.
The fix, as a flow
Three readings per branch and only one of them can be refused. The boolean on the branch object and the ruleset rules for the branch are both published to anyone who can read the repository, so a run with a read-only token still resolves coverage and often still resolves the rules; it is the classic protection object that needs admin. Everything after the three reads is pure: which of the three answers arrived, what the rules refuse a push for, and a summary that carries an unknown column so that a gap in the instrument can never be added to the pile of findings about the estate.
How to fix it
Read the branch before you read its rules
One GET /repos/{owner}/{repo}/branches/{branch}. The protected boolean in that response is visible to any token that can read the repository, and it is the field a coverage report should be built on. Everything after this step is about how the branch is protected, which is a different and more expensive question.
Ask for the protection rules and keep the refusal as a refusal
One GET /repos/{owner}/{repo}/branches/{branch}/protection, and then three branches rather than two. Only a 404 whose message is Branch not protected is evidence of absence. A 403 is evidence about your token, and the script records it as unknown so it can never be counted as a repository at risk.
Read the ruleset rules, which need no admin at all
One GET /repos/{owner}/{repo}/rules/branches/{branch}. This returns the rules contributed by repository and organisation rulesets along with the ruleset each came from, and it answers with read access. On an estate that has moved to rulesets this is often the only readable description of what governs the branch, and it turns a row that would have said “cannot see” into a row that says “pull request required, two approvals, non-fast-forward blocked”.
Turn the readable rules into the writes they refuse
The script converts whatever it could read into plain statements about what will happen to a push: refused outright, refused until named checks pass, allowed only for listed actors, allowed for administrators or not. That is the part people actually want from a protection audit, and it comes out of fields rather than out of an experiment. No commit is created, no branch is touched, nothing is pushed.
Report unknown as a number, not as a zero
The summary prints four counts: protected with readable rules, protected but opaque, confirmed unprotected, and unknown. If the unknown column is large, the finding is about the auditing token and not about the estate, and the repair is to grant repository admin or an App with administration: read where the detail is needed. Three requests per branch, printed before they are spent.
How to check it worked
Run it once with the read-only auditing token and once with a token that has admin on one repository. The estate does not change between the two runs; the number of rows the instrument can resolve does.
GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_branch_protection_audit.py \
--branch acme/platform-api:main --branch acme/legacy-tools:main
# read cost: at most 3 request(s) per branch against the core hourly quota
# read cost: at most 6 request(s) in total
# acme/platform-api:main protected=True protection=403 rules=2
# protected-rules-hidden: the branch reports protected=true and the protection
# endpoint refused with admin rights required, so the classic rules are not
# readable by this token. Two ruleset rule(s) are readable and are reported.
# a pull request is required, so a direct push to this branch is refused
# non-fast-forward updates are blocked, so a force push is refused
# repair: report this as protected. To read the detailed rules, grant this token
# repository admin or use an App with administration: read.
# acme/legacy-tools:main protected=False protection=404 rules=0
# unprotected-confirmed: protected=false and the protection endpoint answered
# 404 Branch not protected, which is the one 404 that means absence
# repair: this branch really is unprotected. Protect it or record the exception.
#
# summary: 1 protected, 0 readable in detail, 1 unprotected, 0 unknown
The full code
The whole note is one function that refuses to fold three outcomes into two, and everything else exists to give that function something to be careful with. Three reads per branch, all of them cheap, and the classification afterwards is pure: which of the three answers came back, what the branch object said about itself, and what the ruleset listing published. The translation from rules to refused writes is also pure, which is the point — a protection audit that has to push a commit to find out what protection does is not an audit, it is a change.
"""Audit branch protection without mistaking a refusal for an absence.
Read only. Three GETs per branch and nothing is written: no commit is created,
no ref is updated, no protection setting is changed. What a push would be
refused for is derived from the rules the API publishes, never by attempting a
push and reading the error.
The point of the note: the detailed protection rules are readable only with
repository admin. Without it, GET .../protection answers 403 with an
admin-rights message, and an auditor that treats every non-200 as "not
protected" reports a fully protected estate as wide open. Only a 404 whose
message is "Branch not protected" is evidence of absence.
What this can and cannot see: with a read-only token the classic rules on a
protected branch are genuinely invisible, and that is reported as unknown
rather than guessed at. Two things are visible without admin and are used
instead -- the protected boolean on the branch object, and the rules a ruleset
contributes, which GET /repos/{owner}/{repo}/rules/branches/{branch} publishes
to anyone who can read the repository.
Environment:
GITHUB_TOKEN a token with read access to the repositories
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_branch_protection_audit")
API = "https://api.github.com"
UA = "github-branch-protection-audit/1.0"
# The one message that turns a 404 into a finding. Anything else answering 404
# is ambiguous and belongs to the 404 triage note instead.
ABSENCE_MESSAGE = "branch not protected"
# What the protection endpoint says when the token can reach the repository but
# not its settings. Matched loosely because the wording has varied.
ADMIN_MESSAGE = "admin rights"
# Three reads per branch: the branch, the classic protection, the ruleset rules.
REQUESTS_PER_BRANCH = 3
def is_absence(status, message):
"""Whether this answer is evidence that the branch is unprotected. Pure.
The single most important function in this script, and the one the broken
auditors are missing. A 403 is never an absence: it says the token cannot
see, which is a statement about the instrument rather than about the
branch. A 404 is an absence only when it names the reason.
"""
try:
code = int(status)
except (TypeError, ValueError):
return False
if code != 404:
return False
return ABSENCE_MESSAGE in str(message or "").lower()
def visibility(status, message):
"""What the protection endpoint's answer tells you. Pure.
Returns one of: readable, not-protected, admin-required, ambiguous-404,
unknown. Four of those five are not measurements of the branch.
"""
try:
code = int(status)
except (TypeError, ValueError):
return "unknown"
if code == 200:
return "readable"
if is_absence(code, message):
return "not-protected"
if code == 403:
return "admin-required"
if code == 404:
return "ambiguous-404"
return "unknown"
def verdict(protected_flag, status, message, rules=None):
"""Classify one branch from all three readings. Pure. (state, detail).
The branch object's boolean is authoritative for coverage because it is
visible without admin. The protection endpoint decides only whether the
detail is available, and the ruleset listing can rescue a row that would
otherwise have been opaque.
"""
seen = visibility(status, message)
rule_count = len(rules or [])
if protected_flag is None:
return ("branch-unreadable",
"the branch itself did not come back, so there is nothing to "
"judge. That is a repository or credential problem rather than "
"a protection one.")
if protected_flag:
if seen == "readable":
return ("protected-rules-readable",
"the branch is protected and this token can read the rules, "
"so the refusals below are quoted from settings rather than "
"inferred.")
if seen == "admin-required":
detail = ("the branch reports protected=true and the protection "
"endpoint refused with admin rights required, so the "
"classic rules are not readable by this token.")
if rule_count:
detail += (" %d ruleset rule(s) are readable and are reported."
% rule_count)
return ("protected-rules-hidden", detail)
if seen == "not-protected":
return ("contradictory",
"the branch says protected=true and the protection endpoint "
"says the branch is not protected. A ruleset governs this "
"branch without classic branch protection behind it.")
return ("protected-rules-hidden",
"the branch reports protected=true and the protection endpoint "
"answered %s, which is not a readable rule set. Treat this as "
"protected and unmeasured." % status)
if rule_count:
return ("ruleset-only",
"protected=false, but %d rule(s) reach this branch from a "
"ruleset. Classic protection is not the only thing that refuses "
"a push." % rule_count)
if seen == "not-protected":
return ("unprotected-confirmed",
"protected=false and the protection endpoint answered 404 "
"Branch not protected, which is the one 404 that means absence.")
if seen == "admin-required":
return ("unprotected-by-flag",
"protected=false on the branch object, which is visible without "
"admin and is the honest reading. The protection endpoint "
"refused separately and adds nothing here.")
return ("unknown",
"protected=false but the protection endpoint answered %s rather "
"than a recognised absence, so this row is not resolved." % status)
def refused_writes(protection):
"""Plain statements of what the classic rules refuse. Pure.
Derived from fields. Nothing here is learned by pushing anything.
"""
if not isinstance(protection, dict):
return []
out = []
reviews = protection.get("required_pull_request_reviews")
if isinstance(reviews, dict):
count = reviews.get("required_approving_review_count")
if count:
out.append("a direct push is refused: %s approving review(s) are "
"required through a pull request" % count)
else:
out.append("a direct push is refused: a pull request is required")
checks = protection.get("required_status_checks")
if isinstance(checks, dict):
contexts = checks.get("contexts") or []
out.append("a merge is refused until %d status check(s) pass"
% len(contexts))
if checks.get("strict"):
out.append("a merge is refused while the branch is behind its base")
if (protection.get("enforce_admins") or {}).get("enabled"):
out.append("administrators are not exempt from any of the above")
restrictions = protection.get("restrictions")
if isinstance(restrictions, dict):
actors = (len(restrictions.get("users") or [])
+ len(restrictions.get("teams") or [])
+ len(restrictions.get("apps") or []))
out.append("a push is refused for everyone except %d listed actor(s)"
% actors)
if (protection.get("required_signatures") or {}).get("enabled"):
out.append("an unsigned commit is refused")
if (protection.get("lock_branch") or {}).get("enabled"):
out.append("the branch is locked, so every write is refused")
force = protection.get("allow_force_pushes")
if isinstance(force, dict) and not force.get("enabled"):
out.append("a force push is refused")
deletions = protection.get("allow_deletions")
if isinstance(deletions, dict) and not deletions.get("enabled"):
out.append("deleting the branch is refused")
return out
def refused_by_rules(rules):
"""The same statements, from the ruleset listing. Pure.
This is the half that needs no admin, so on a read-only run it is often
the only description of the branch anybody gets.
"""
if not isinstance(rules, list):
return []
kinds = [r.get("type") for r in rules if isinstance(r, dict)]
out = []
if "pull_request" in kinds:
out.append("a pull request is required, so a direct push to this "
"branch is refused")
if "required_status_checks" in kinds:
out.append("a merge is refused until the ruleset's status checks pass")
if "non_fast_forward" in kinds:
out.append("non-fast-forward updates are blocked, so a force push is "
"refused")
if "deletion" in kinds:
out.append("deleting the branch is refused")
if "creation" in kinds:
out.append("creating this ref is refused")
if "update" in kinds:
out.append("updating this ref directly is refused")
if "required_signatures" in kinds:
out.append("an unsigned commit is refused")
return out
def rulesets_named(rules):
"""Which rulesets contributed the rules, for the report. Pure."""
names = []
for rule in rules or []:
if not isinstance(rule, dict):
continue
source = rule.get("ruleset_source") or rule.get("ruleset_source_type")
if source and source not in names:
names.append(source)
return names
def push_allowlist(protection):
"""Who is allowed to push to a restricted branch. Pure. Names only."""
restrictions = (protection or {}).get("restrictions")
if not isinstance(restrictions, dict):
return []
out = []
for user in restrictions.get("users") or []:
if isinstance(user, dict) and user.get("login"):
out.append("user:" + str(user["login"]))
for team in restrictions.get("teams") or []:
if isinstance(team, dict) and team.get("slug"):
out.append("team:" + str(team["slug"]))
for app in restrictions.get("apps") or []:
if isinstance(app, dict) and app.get("slug"):
out.append("app:" + str(app["slug"]))
return out
def coverage(states):
"""Summarise a sweep without letting unknown become unprotected. Pure."""
counts = {"protected": 0, "readable_in_detail": 0, "unprotected": 0,
"unknown": 0}
for state in states or []:
if state in ("protected-rules-readable", "protected-rules-hidden",
"contradictory", "ruleset-only"):
counts["protected"] += 1
if state == "protected-rules-readable":
counts["readable_in_detail"] += 1
elif state in ("unprotected-confirmed", "unprotected-by-flag"):
counts["unprotected"] += 1
else:
counts["unknown"] += 1
return counts
def instrument_verdict(counts):
"""Whether the sweep measured the estate or measured its own token. Pure."""
counts = counts or {}
protected = int(counts.get("protected") or 0)
detail = int(counts.get("readable_in_detail") or 0)
unknown = int(counts.get("unknown") or 0)
total = protected + int(counts.get("unprotected") or 0) + unknown
if not total:
return ("no-rows", "nothing was checked.")
if unknown:
return ("instrument-gap",
"%d of %d row(s) are unresolved. Those are not findings about "
"the estate." % (unknown, total))
if protected and not detail:
return ("coverage-only",
"every protected branch was counted from its boolean and none "
"of the classic rules were readable. Coverage is trustworthy, "
"detail is absent.")
return ("measured", "every row resolved to a state about the branch.")
def repair(state):
"""The sentence a reader has to act on. Pure."""
if state == "protected-rules-hidden":
return ("report this as protected. To read the detailed rules, grant "
"this token repository admin or use an App with "
"administration: read.")
if state == "protected-rules-readable":
return ("nothing on visibility. Check the rules against your policy; "
"the refusals above are what a push actually meets.")
if state == "unprotected-confirmed":
return ("this branch really is unprotected. Protect it or record the "
"exception.")
if state == "unprotected-by-flag":
return ("this branch is unprotected on the boolean that needs no "
"admin. Do not upgrade the token to confirm an absence you can "
"already see.")
if state == "ruleset-only":
return ("read the ruleset rather than the branch protection settings. "
"A ruleset refuses pushes without setting protected=true.")
if state == "contradictory":
return ("audit the ruleset that governs this branch. Classic protection "
"is not what is refusing writes here.")
if state == "branch-unreadable":
return ("triage the repository and the token before the protection: "
"check the name, the visibility and the installation.")
return ("record this row as unknown. An unresolved answer is not a finding "
"and must never be counted as unprotected.")
def read_cost(branches):
"""Requests this run will spend against the core quota. Pure."""
return REQUESTS_PER_BRANCH * len(branches or [])
def split_target(target):
"""owner/repo:branch into its three parts. Pure."""
text = str(target or "").strip()
if ":" in text:
repo, branch = text.rsplit(":", 1)
else:
repo, branch = text, "main"
if repo.count("/") != 1 or not branch:
return None
owner, name = repo.split("/")
if not owner or not name:
return None
return (owner, name, branch)
def message_of(body):
"""The message field of an error body, if there is one. Pure."""
if isinstance(body, dict):
return str(body.get("message") or "")
return ""
def get_json(session, path):
"""One GET. Returns (status, parsed-body-or-None). Never writes."""
r = session.get(API + path, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
"or revoked")
if r.status_code == 403 and "rate limit" in r.text.lower():
raise SystemExit("403 rate limited. GET /rate_limit reports the reset "
"time and does not itself consume quota")
try:
return r.status_code, r.json()
except ValueError:
return r.status_code, None
def inspect(session, owner, name, branch):
"""All three readings for one branch. Reads only."""
base = "/repos/%s/%s" % (owner, name)
b_status, b_body = get_json(session, "%s/branches/%s" % (base, branch))
flag = None
if b_status == 200 and isinstance(b_body, dict):
flag = bool(b_body.get("protected"))
p_status, p_body = get_json(session, "%s/branches/%s/protection"
% (base, branch))
protection = p_body if (p_status == 200 and isinstance(p_body, dict)) else None
r_status, r_body = get_json(session, "%s/rules/branches/%s" % (base, branch))
rules = r_body if (r_status == 200 and isinstance(r_body, list)) else []
return {
"branch_status": b_status,
"protected_flag": flag,
"protection_status": p_status,
"protection_message": message_of(p_body),
"protection": protection,
"rules_status": r_status,
"rules": rules,
}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--branch", action="append", required=True,
help="owner/repo:branch to audit. Repeatable. The branch "
"defaults to main when the colon is left off.")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (a read-only token is enough)")
return 2
targets = []
for raw in args.branch:
parts = split_target(raw)
if not parts:
log.error("cannot parse %r: expected owner/repo:branch", raw)
return 2
targets.append(parts)
log.info("read cost: at most %d request(s) per branch against the core "
"hourly quota", REQUESTS_PER_BRANCH)
log.info("read cost: at most %d request(s) in total", read_cost(targets))
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# GitHub rejects requests with no User-Agent outright.
"User-Agent": UA,
})
findings = []
for owner, name, branch in targets:
label = "%s/%s:%s" % (owner, name, branch)
seen = inspect(session, owner, name, branch)
state, detail = verdict(seen["protected_flag"], seen["protection_status"],
seen["protection_message"], seen["rules"])
refusals = refused_writes(seen["protection"]) or refused_by_rules(seen["rules"])
log.info("%s protected=%s protection=%s rules=%d", label,
seen["protected_flag"], seen["protection_status"],
len(seen["rules"]))
log.info("%s: %s", state, detail)
for line in refusals:
log.info(" %s", line)
allowlist = push_allowlist(seen["protection"])
if allowlist:
log.info(" push allowed for: %s", ", ".join(allowlist))
sources = rulesets_named(seen["rules"])
if sources:
log.info(" ruleset source(s): %s", ", ".join(str(s) for s in sources))
log.info("repair: %s", repair(state))
findings.append({
"branch": label,
"protected": seen["protected_flag"],
"protection_status": seen["protection_status"],
"protection_visibility": visibility(seen["protection_status"],
seen["protection_message"]),
"ruleset_rule_count": len(seen["rules"]),
"ruleset_sources": sources,
"refused_writes": refusals,
"push_allowlist": allowlist,
"state": state,
"detail": detail,
"repair": repair(state),
})
counts = coverage([f["state"] for f in findings])
instrument, note = instrument_verdict(counts)
log.info("summary: %d protected, %d readable in detail, %d unprotected, "
"%d unknown", counts["protected"], counts["readable_in_detail"],
counts["unprotected"], counts["unknown"])
log.info("%s: %s", instrument, note)
print(json.dumps({
"requests_spent_at_most": read_cost(targets),
"coverage": counts,
"instrument": {"state": instrument, "detail": note},
"findings": findings,
}, indent=2, default=str))
bad = {"unprotected-confirmed", "unprotected-by-flag"}
return 1 if counts["unknown"] or any(f["state"] in bad for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Audit branch protection without mistaking a refusal for an absence.
*
* Read only. Three GETs per branch and nothing is written: no commit, no ref
* update, no settings change. What a push would be refused for is derived from
* the rules the API publishes, never by attempting a push.
*
* The detailed protection rules need repository admin. Without it,
* GET .../protection answers 403, and only a 404 whose message is "Branch not
* protected" is evidence of absence. The protected boolean on the branch and
* the ruleset rules for the branch are both readable without admin.
*
* Environment:
* GITHUB_TOKEN a token with read access to the repositories
* GITHUB_BRANCHES comma-separated owner/repo:branch values
*/
const API = 'https://api.github.com';
const UA = 'github-branch-protection-audit/1.0';
/** The one message that turns a 404 into a finding. */
export const ABSENCE_MESSAGE = 'branch not protected';
/** Three reads per branch: the branch, the protection, the ruleset rules. */
export const REQUESTS_PER_BRANCH = 3;
/** Whether this answer is evidence that the branch is unprotected. Pure. */
export function isAbsence(status, message) {
const code = Number(status);
if (!Number.isFinite(code) || code !== 404) return false;
return String(message ?? '').toLowerCase().includes(ABSENCE_MESSAGE);
}
/** What the protection endpoint's answer tells you. Pure. */
export function visibility(status, message) {
const code = Number(status);
if (!Number.isFinite(code)) return 'unknown';
if (code === 200) return 'readable';
if (isAbsence(code, message)) return 'not-protected';
if (code === 403) return 'admin-required';
if (code === 404) return 'ambiguous-404';
return 'unknown';
}
/** Classify one branch from all three readings. Pure. [state, detail]. */
export function verdict(protectedFlag, status, message, rules = []) {
const seen = visibility(status, message);
const ruleCount = Array.isArray(rules) ? rules.length : 0;
if (protectedFlag === null || protectedFlag === undefined) {
return ['branch-unreadable', 'the branch itself did not come back, so there '
+ 'is nothing to judge. That is a repository or credential problem rather '
+ 'than a protection one.'];
}
if (protectedFlag) {
if (seen === 'readable') {
return ['protected-rules-readable', 'the branch is protected and this '
+ 'token can read the rules, so the refusals below are quoted from '
+ 'settings rather than inferred.'];
}
if (seen === 'admin-required') {
let detail = 'the branch reports protected=true and the protection '
+ 'endpoint refused with admin rights required, so the classic rules '
+ 'are not readable by this token.';
if (ruleCount) {
detail += ` ${ruleCount} ruleset rule(s) are readable and are reported.`;
}
return ['protected-rules-hidden', detail];
}
if (seen === 'not-protected') {
return ['contradictory', 'the branch says protected=true and the '
+ 'protection endpoint says the branch is not protected. A ruleset '
+ 'governs this branch without classic branch protection behind it.'];
}
return ['protected-rules-hidden', `the branch reports protected=true and `
+ `the protection endpoint answered ${status}, which is not a readable `
+ 'rule set. Treat this as protected and unmeasured.'];
}
if (ruleCount) {
return ['ruleset-only', `protected=false, but ${ruleCount} rule(s) reach `
+ 'this branch from a ruleset. Classic protection is not the only thing '
+ 'that refuses a push.'];
}
if (seen === 'not-protected') {
return ['unprotected-confirmed', 'protected=false and the protection '
+ 'endpoint answered 404 Branch not protected, which is the one 404 that '
+ 'means absence.'];
}
if (seen === 'admin-required') {
return ['unprotected-by-flag', 'protected=false on the branch object, which '
+ 'is visible without admin and is the honest reading. The protection '
+ 'endpoint refused separately and adds nothing here.'];
}
return ['unknown', `protected=false but the protection endpoint answered `
+ `${status} rather than a recognised absence, so this row is not resolved.`];
}
/** Plain statements of what the classic rules refuse. Pure. */
export function refusedWrites(protection) {
if (!protection || typeof protection !== 'object') return [];
const out = [];
const reviews = protection.required_pull_request_reviews;
if (reviews && typeof reviews === 'object') {
const count = reviews.required_approving_review_count;
if (count) {
out.push(`a direct push is refused: ${count} approving review(s) are `
+ 'required through a pull request');
} else {
out.push('a direct push is refused: a pull request is required');
}
}
const checks = protection.required_status_checks;
if (checks && typeof checks === 'object') {
const contexts = checks.contexts || [];
out.push(`a merge is refused until ${contexts.length} status check(s) pass`);
if (checks.strict) {
out.push('a merge is refused while the branch is behind its base');
}
}
if ((protection.enforce_admins || {}).enabled) {
out.push('administrators are not exempt from any of the above');
}
const restrictions = protection.restrictions;
if (restrictions && typeof restrictions === 'object') {
const actors = (restrictions.users || []).length
+ (restrictions.teams || []).length + (restrictions.apps || []).length;
out.push(`a push is refused for everyone except ${actors} listed actor(s)`);
}
if ((protection.required_signatures || {}).enabled) {
out.push('an unsigned commit is refused');
}
if ((protection.lock_branch || {}).enabled) {
out.push('the branch is locked, so every write is refused');
}
const force = protection.allow_force_pushes;
if (force && typeof force === 'object' && !force.enabled) {
out.push('a force push is refused');
}
const deletions = protection.allow_deletions;
if (deletions && typeof deletions === 'object' && !deletions.enabled) {
out.push('deleting the branch is refused');
}
return out;
}
/** The same statements, from the ruleset listing. Pure. */
export function refusedByRules(rules) {
if (!Array.isArray(rules)) return [];
const kinds = rules.filter((r) => r && typeof r === 'object').map((r) => r.type);
const out = [];
if (kinds.includes('pull_request')) {
out.push('a pull request is required, so a direct push to this branch is refused');
}
if (kinds.includes('required_status_checks')) {
out.push("a merge is refused until the ruleset's status checks pass");
}
if (kinds.includes('non_fast_forward')) {
out.push('non-fast-forward updates are blocked, so a force push is refused');
}
if (kinds.includes('deletion')) out.push('deleting the branch is refused');
if (kinds.includes('creation')) out.push('creating this ref is refused');
if (kinds.includes('update')) out.push('updating this ref directly is refused');
if (kinds.includes('required_signatures')) out.push('an unsigned commit is refused');
return out;
}
/** Which rulesets contributed the rules, for the report. Pure. */
export function rulesetsNamed(rules) {
const names = [];
for (const rule of rules || []) {
if (!rule || typeof rule !== 'object') continue;
const source = rule.ruleset_source || rule.ruleset_source_type;
if (source && !names.includes(source)) names.push(source);
}
return names;
}
/** Who is allowed to push to a restricted branch. Pure. Names only. */
export function pushAllowlist(protection) {
const restrictions = (protection || {}).restrictions;
if (!restrictions || typeof restrictions !== 'object') return [];
const out = [];
for (const user of restrictions.users || []) {
if (user && user.login) out.push(`user:${user.login}`);
}
for (const team of restrictions.teams || []) {
if (team && team.slug) out.push(`team:${team.slug}`);
}
for (const app of restrictions.apps || []) {
if (app && app.slug) out.push(`app:${app.slug}`);
}
return out;
}
/** Summarise a sweep without letting unknown become unprotected. Pure. */
export function coverage(states) {
const counts = {
protected: 0, readable_in_detail: 0, unprotected: 0, unknown: 0,
};
for (const state of states || []) {
if (['protected-rules-readable', 'protected-rules-hidden', 'contradictory',
'ruleset-only'].includes(state)) {
counts.protected += 1;
if (state === 'protected-rules-readable') counts.readable_in_detail += 1;
} else if (['unprotected-confirmed', 'unprotected-by-flag'].includes(state)) {
counts.unprotected += 1;
} else {
counts.unknown += 1;
}
}
return counts;
}
/** Whether the sweep measured the estate or measured its own token. Pure. */
export function instrumentVerdict(counts) {
const c = counts || {};
const protectedCount = Number(c.protected) || 0;
const detail = Number(c.readable_in_detail) || 0;
const unknown = Number(c.unknown) || 0;
const total = protectedCount + (Number(c.unprotected) || 0) + unknown;
if (!total) return ['no-rows', 'nothing was checked.'];
if (unknown) {
return ['instrument-gap', `${unknown} of ${total} row(s) are unresolved. `
+ 'Those are not findings about the estate.'];
}
if (protectedCount && !detail) {
return ['coverage-only', 'every protected branch was counted from its '
+ 'boolean and none of the classic rules were readable. Coverage is '
+ 'trustworthy, detail is absent.'];
}
return ['measured', 'every row resolved to a state about the branch.'];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state) {
if (state === 'protected-rules-hidden') {
return 'report this as protected. To read the detailed rules, grant this '
+ 'token repository admin or use an App with administration: read.';
}
if (state === 'protected-rules-readable') {
return 'nothing on visibility. Check the rules against your policy; the '
+ 'refusals above are what a push actually meets.';
}
if (state === 'unprotected-confirmed') {
return 'this branch really is unprotected. Protect it or record the exception.';
}
if (state === 'unprotected-by-flag') {
return 'this branch is unprotected on the boolean that needs no admin. Do '
+ 'not upgrade the token to confirm an absence you can already see.';
}
if (state === 'ruleset-only') {
return 'read the ruleset rather than the branch protection settings. A '
+ 'ruleset refuses pushes without setting protected=true.';
}
if (state === 'contradictory') {
return 'audit the ruleset that governs this branch. Classic protection is '
+ 'not what is refusing writes here.';
}
if (state === 'branch-unreadable') {
return 'triage the repository and the token before the protection: check '
+ 'the name, the visibility and the installation.';
}
return 'record this row as unknown. An unresolved answer is not a finding and '
+ 'must never be counted as unprotected.';
}
/** Requests this run will spend against the core quota. Pure. */
export function readCost(branches) {
return REQUESTS_PER_BRANCH * ((branches || []).length);
}
/** owner/repo:branch into its three parts. Pure. */
export function splitTarget(target) {
const text = String(target ?? '').trim();
const at = text.lastIndexOf(':');
const repo = at > 0 ? text.slice(0, at) : text;
const branch = at > 0 ? text.slice(at + 1) : 'main';
const parts = repo.split('/');
if (parts.length !== 2 || !parts[0] || !parts[1] || !branch) return null;
return [parts[0], parts[1], branch];
}
function headers(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
}
async function getJson(token, path) {
const res = await fetch(`${API}${path}`, { headers: headers(token) });
let body = null;
try { body = await res.json(); } catch { body = null; }
return { status: res.status, body };
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const raw = process.env.GITHUB_BRANCHES;
if (!token || !raw) {
console.error('set GITHUB_TOKEN (read-only is enough) and GITHUB_BRANCHES');
process.exitCode = 2;
return;
}
const targets = raw.split(',').map((t) => splitTarget(t)).filter(Boolean);
if (!targets.length) {
console.error('GITHUB_BRANCHES should hold owner/repo:branch values');
process.exitCode = 2;
return;
}
console.log(`read cost: at most ${REQUESTS_PER_BRANCH} request(s) per branch `
+ 'against the core hourly quota');
console.log(`read cost: at most ${readCost(targets)} request(s) in total`);
const findings = [];
for (const [owner, name, branch] of targets) {
const base = `/repos/${owner}/${name}`;
const b = await getJson(token, `${base}/branches/${branch}`);
const flag = b.status === 200 && b.body ? Boolean(b.body.protected) : null;
const p = await getJson(token, `${base}/branches/${branch}/protection`);
const protection = p.status === 200 && p.body ? p.body : null;
const r = await getJson(token, `${base}/rules/branches/${branch}`);
const rules = r.status === 200 && Array.isArray(r.body) ? r.body : [];
const message = p.body && typeof p.body === 'object' ? p.body.message : '';
const [state, detail] = verdict(flag, p.status, message, rules);
const refusals = refusedWrites(protection).length
? refusedWrites(protection) : refusedByRules(rules);
const label = `${owner}/${name}:${branch}`;
console.log(`${label} protected=${flag} protection=${p.status} rules=${rules.length}`);
console.log(`${state}: ${detail}`);
for (const line of refusals) console.log(` ${line}`);
console.log(`repair: ${repair(state)}`);
findings.push({
branch: label,
protected: flag,
protection_status: p.status,
protection_visibility: visibility(p.status, message),
ruleset_rule_count: rules.length,
ruleset_sources: rulesetsNamed(rules),
refused_writes: refusals,
push_allowlist: pushAllowlist(protection),
state,
detail,
repair: repair(state),
});
}
const counts = coverage(findings.map((f) => f.state));
const [instrument, note] = instrumentVerdict(counts);
console.log(`summary: ${counts.protected} protected, `
+ `${counts.readable_in_detail} readable in detail, ${counts.unprotected} `
+ `unprotected, ${counts.unknown} unknown`);
console.log(`${instrument}: ${note}`);
console.log(JSON.stringify({
requests_spent_at_most: readCost(targets),
coverage: counts,
instrument: { state: instrument, detail: note },
findings,
}, null, 2));
const bad = ['unprotected-confirmed', 'unprotected-by-flag'];
process.exitCode = counts.unknown
|| findings.some((f) => bad.includes(f.state)) ? 1 : 0;
}
// Guarded so importing this file from the test runner does not start a run and
// fail the suite even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The first assertion is the one the broken auditors would fail: a 403 is not an absence, and no message on a 403 can make it one. From there the tests pin each of the three outcomes to its own state, check that the branch object's boolean is trusted over the endpoint that refused, and confirm that a branch governed only by a ruleset is still reported as refusing pushes. The summary is tested separately because that is where the damage was done originally — an unknown row must survive as unknown all the way into the counts rather than being quietly added to the unprotected pile.
from github_branch_protection_audit import (
REQUESTS_PER_BRANCH, coverage, instrument_verdict, is_absence,
push_allowlist, read_cost, refused_by_rules, refused_writes, repair,
rulesets_named, split_target, verdict, visibility,
)
ADMIN_403 = "Must have admin rights to Repository."
ABSENT_404 = "Branch not protected"
PROTECTION = {
"required_pull_request_reviews": {"required_approving_review_count": 2},
"required_status_checks": {"strict": True, "contexts": ["build", "lint"]},
"enforce_admins": {"enabled": True},
"restrictions": {"users": [{"login": "release-bot"}],
"teams": [{"slug": "platform"}], "apps": []},
"required_signatures": {"enabled": False},
"allow_force_pushes": {"enabled": False},
"allow_deletions": {"enabled": False},
}
RULES = [
{"type": "pull_request", "ruleset_id": 42, "ruleset_source": "acme"},
{"type": "non_fast_forward", "ruleset_id": 42, "ruleset_source": "acme"},
]
def test_a_403_is_never_evidence_that_a_branch_is_unprotected():
assert is_absence(403, ADMIN_403) is False
# Not even when the refusal happens to carry the absence wording.
assert is_absence(403, ABSENT_404) is False
assert visibility(403, ADMIN_403) == "admin-required"
def test_only_a_404_that_names_the_reason_is_an_absence():
assert is_absence(404, ABSENT_404) is True
assert is_absence(404, "Not Found") is False
assert visibility(404, ABSENT_404) == "not-protected"
assert visibility(404, "Not Found") == "ambiguous-404"
def test_the_three_outcomes_stay_three():
assert visibility(200, "") == "readable"
assert visibility(500, "") == "unknown"
assert visibility(None, "") == "unknown"
def test_a_refused_read_on_a_protected_branch_is_protected_and_unmeasured():
state, detail = verdict(True, 403, ADMIN_403, RULES)
assert state == "protected-rules-hidden"
assert "not readable by this token" in detail
assert "2 ruleset rule(s)" in detail
assert "administration: read" in repair(state)
def test_a_protected_branch_with_readable_rules_is_the_measured_case():
state, _ = verdict(True, 200, "", [])
assert state == "protected-rules-readable"
def test_an_unprotected_branch_needs_both_readings_to_agree():
assert verdict(False, 404, ABSENT_404, [])[0] == "unprotected-confirmed"
state, detail = verdict(False, 403, ADMIN_403, [])
assert state == "unprotected-by-flag"
assert "visible without admin" in detail
assert "already see" in repair(state)
def test_a_ruleset_protects_a_branch_that_reports_protected_false():
state, detail = verdict(False, 404, ABSENT_404, RULES)
assert state == "ruleset-only"
assert "from a ruleset" in detail
assert "read the ruleset" in repair(state)
def test_a_branch_that_did_not_come_back_is_not_a_protection_finding():
state, _ = verdict(None, 404, "Not Found", [])
assert state == "branch-unreadable"
assert "triage the repository" in repair(state)
def test_the_refusals_are_derived_from_fields_not_from_a_push():
lines = refused_writes(PROTECTION)
assert "a direct push is refused: 2 approving review(s) are required through a pull request" in lines
assert "a merge is refused until 2 status check(s) pass" in lines
assert "a merge is refused while the branch is behind its base" in lines
assert "administrators are not exempt from any of the above" in lines
assert "a push is refused for everyone except 2 listed actor(s)" in lines
assert "a force push is refused" in lines
assert "deleting the branch is refused" in lines
assert refused_writes(None) == []
def test_an_unsigned_commit_rule_is_only_reported_when_enabled():
assert "an unsigned commit is refused" not in refused_writes(PROTECTION)
signed = dict(PROTECTION, required_signatures={"enabled": True})
assert "an unsigned commit is refused" in refused_writes(signed)
def test_a_locked_branch_refuses_everything():
locked = dict(PROTECTION, lock_branch={"enabled": True})
assert "the branch is locked, so every write is refused" in refused_writes(locked)
def test_the_ruleset_listing_describes_the_same_refusals_without_admin():
lines = refused_by_rules(RULES)
assert "a pull request is required, so a direct push to this branch is refused" in lines
assert "non-fast-forward updates are blocked, so a force push is refused" in lines
assert refused_by_rules([]) == []
assert refused_by_rules("not a list") == []
assert rulesets_named(RULES) == ["acme"]
def test_the_allowlist_reports_names_and_nothing_else():
assert push_allowlist(PROTECTION) == ["user:release-bot", "team:platform"]
assert push_allowlist({}) == []
assert push_allowlist(None) == []
def test_an_unknown_row_never_becomes_an_unprotected_row():
counts = coverage(["protected-rules-hidden", "unknown", "branch-unreadable",
"unprotected-confirmed", "protected-rules-readable"])
assert counts == {"protected": 2, "readable_in_detail": 1,
"unprotected": 1, "unknown": 2}
state, detail = instrument_verdict(counts)
assert state == "instrument-gap"
assert "2 of 5" in detail
def test_a_sweep_with_no_detail_says_so_rather_than_claiming_a_measurement():
counts = coverage(["protected-rules-hidden", "protected-rules-hidden"])
state, detail = instrument_verdict(counts)
assert state == "coverage-only"
assert "detail is absent" in detail
assert instrument_verdict({})[0] == "no-rows"
assert instrument_verdict(coverage(["protected-rules-readable"]))[0] == "measured"
def test_targets_and_cost_are_worked_out_before_anything_is_fetched():
assert split_target("acme/platform-api:release/2.1") == (
"acme", "platform-api", "release/2.1")
assert split_target("acme/platform-api") == ("acme", "platform-api", "main")
assert split_target("platform-api") is None
assert split_target("") is None
assert REQUESTS_PER_BRANCH == 3
assert read_cost(["a", "b"]) == 6
assert read_cost([]) == 0
assert read_cost(None) == 0
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
REQUESTS_PER_BRANCH, coverage, instrumentVerdict, isAbsence, pushAllowlist,
readCost, refusedByRules, refusedWrites, repair, rulesetsNamed, splitTarget,
verdict, visibility,
} from './github-branch-protection-audit.mjs';
const ADMIN_403 = 'Must have admin rights to Repository.';
const ABSENT_404 = 'Branch not protected';
const PROTECTION = {
required_pull_request_reviews: { required_approving_review_count: 2 },
required_status_checks: { strict: true, contexts: ['build', 'lint'] },
enforce_admins: { enabled: true },
restrictions: {
users: [{ login: 'release-bot' }], teams: [{ slug: 'platform' }], apps: [],
},
required_signatures: { enabled: false },
allow_force_pushes: { enabled: false },
allow_deletions: { enabled: false },
};
const RULES = [
{ type: 'pull_request', ruleset_id: 42, ruleset_source: 'acme' },
{ type: 'non_fast_forward', ruleset_id: 42, ruleset_source: 'acme' },
];
test('a 403 is never evidence that a branch is unprotected', () => {
assert.equal(isAbsence(403, ADMIN_403), false);
assert.equal(isAbsence(403, ABSENT_404), false);
assert.equal(visibility(403, ADMIN_403), 'admin-required');
});
test('only a 404 that names the reason is an absence', () => {
assert.equal(isAbsence(404, ABSENT_404), true);
assert.equal(isAbsence(404, 'Not Found'), false);
assert.equal(visibility(404, ABSENT_404), 'not-protected');
assert.equal(visibility(404, 'Not Found'), 'ambiguous-404');
});
test('the three outcomes stay three', () => {
assert.equal(visibility(200, ''), 'readable');
assert.equal(visibility(500, ''), 'unknown');
assert.equal(visibility(null, ''), 'unknown');
});
test('a refused read on a protected branch is protected and unmeasured', () => {
const [state, detail] = verdict(true, 403, ADMIN_403, RULES);
assert.equal(state, 'protected-rules-hidden');
assert.match(detail, /not readable by this token/);
assert.match(detail, /2 ruleset rule\(s\)/);
assert.match(repair(state), /administration: read/);
});
test('a protected branch with readable rules is the measured case', () => {
assert.equal(verdict(true, 200, '', [])[0], 'protected-rules-readable');
});
test('an unprotected branch needs both readings to agree', () => {
assert.equal(verdict(false, 404, ABSENT_404, [])[0], 'unprotected-confirmed');
const [state, detail] = verdict(false, 403, ADMIN_403, []);
assert.equal(state, 'unprotected-by-flag');
assert.match(detail, /visible without admin/);
assert.match(repair(state), /already see/);
});
test('a ruleset protects a branch that reports protected false', () => {
const [state, detail] = verdict(false, 404, ABSENT_404, RULES);
assert.equal(state, 'ruleset-only');
assert.match(detail, /from a ruleset/);
assert.match(repair(state), /read the ruleset/);
});
test('a branch that did not come back is not a protection finding', () => {
const [state] = verdict(null, 404, 'Not Found', []);
assert.equal(state, 'branch-unreadable');
assert.match(repair(state), /triage the repository/);
});
test('the refusals are derived from fields not from a push', () => {
const lines = refusedWrites(PROTECTION);
assert.ok(lines.includes('a direct push is refused: 2 approving review(s) are '
+ 'required through a pull request'));
assert.ok(lines.includes('a merge is refused until 2 status check(s) pass'));
assert.ok(lines.includes('a merge is refused while the branch is behind its base'));
assert.ok(lines.includes('administrators are not exempt from any of the above'));
assert.ok(lines.includes('a push is refused for everyone except 2 listed actor(s)'));
assert.ok(lines.includes('a force push is refused'));
assert.ok(lines.includes('deleting the branch is refused'));
assert.deepEqual(refusedWrites(null), []);
});
test('an unsigned commit rule is only reported when enabled', () => {
assert.ok(!refusedWrites(PROTECTION).includes('an unsigned commit is refused'));
const signed = { ...PROTECTION, required_signatures: { enabled: true } };
assert.ok(refusedWrites(signed).includes('an unsigned commit is refused'));
});
test('a locked branch refuses everything', () => {
const locked = { ...PROTECTION, lock_branch: { enabled: true } };
assert.ok(refusedWrites(locked).includes(
'the branch is locked, so every write is refused'));
});
test('the ruleset listing describes the same refusals without admin', () => {
const lines = refusedByRules(RULES);
assert.ok(lines.includes(
'a pull request is required, so a direct push to this branch is refused'));
assert.ok(lines.includes(
'non-fast-forward updates are blocked, so a force push is refused'));
assert.deepEqual(refusedByRules([]), []);
assert.deepEqual(refusedByRules('not a list'), []);
assert.deepEqual(rulesetsNamed(RULES), ['acme']);
});
test('the allowlist reports names and nothing else', () => {
assert.deepEqual(pushAllowlist(PROTECTION), ['user:release-bot', 'team:platform']);
assert.deepEqual(pushAllowlist({}), []);
assert.deepEqual(pushAllowlist(null), []);
});
test('an unknown row never becomes an unprotected row', () => {
const counts = coverage(['protected-rules-hidden', 'unknown',
'branch-unreadable', 'unprotected-confirmed', 'protected-rules-readable']);
assert.deepEqual(counts, {
protected: 2, readable_in_detail: 1, unprotected: 1, unknown: 2,
});
const [state, detail] = instrumentVerdict(counts);
assert.equal(state, 'instrument-gap');
assert.match(detail, /2 of 5/);
});
test('a sweep with no detail says so rather than claiming a measurement', () => {
const counts = coverage(['protected-rules-hidden', 'protected-rules-hidden']);
const [state, detail] = instrumentVerdict(counts);
assert.equal(state, 'coverage-only');
assert.match(detail, /detail is absent/);
assert.equal(instrumentVerdict({})[0], 'no-rows');
assert.equal(instrumentVerdict(coverage(['protected-rules-readable']))[0], 'measured');
});
test('targets and cost are worked out before anything is fetched', () => {
assert.deepEqual(splitTarget('acme/platform-api:release/2.1'),
['acme', 'platform-api', 'release/2.1']);
assert.deepEqual(splitTarget('acme/platform-api'), ['acme', 'platform-api', 'main']);
assert.equal(splitTarget('platform-api'), null);
assert.equal(splitTarget(''), null);
assert.equal(REQUESTS_PER_BRANCH, 3);
assert.equal(readCost(['a', 'b']), 6);
assert.equal(readCost([]), 0);
assert.equal(readCost(null), 0);
});
FAQ
Why not just catch the error and assume no protection?
Because the two errors mean opposite things and only one of them is about the branch. A 404 saying “Branch not protected” is a measurement: the branch has no protection and the API is telling you so. A 403 saying admin rights are required is a measurement of your token: the branch may be protected to the hilt and you are not allowed to look. Folding them together produces a compliance report whose numbers depend on who ran it, which is worse than no report, because it will be believed once and then distrusted forever.
Do I have to give the auditing token repository admin?
Only if you need the detail. The <code>protected</code> boolean on the branch object answers the coverage question and needs nothing beyond read access, and the ruleset rules for a branch are published to anyone who can read the repository. Repository admin brings settings, collaborators, deploy keys and deletion with it, which is a lot to hand a scheduled job for the sake of one field. Where the detailed rules genuinely matter, a GitHub App with <code>administration: read</code> is the narrow version of the same grant and is what to reach for.
The branch says protected=false but pushes are still refused. Why?
A ruleset. Rulesets are a separate mechanism from classic branch protection, they can be defined at the organisation level and applied to many repositories at once, and a branch governed entirely by one can refuse pull requests, force pushes and deletions without the classic protection settings existing at all. Read <code>GET /repos/{owner}/{repo}/rules/branches/{branch}</code>, which lists the rules reaching that branch and names the ruleset each came from. The script reports this as its own state rather than as a contradiction.
Should the script push a commit to confirm what is blocked?
No, and it does not. Every script in this section holds a token that can reach real repositories and none of them writes. It is also unnecessary: required reviews, required status checks, the push allowlist, force-push and deletion settings, and whether administrators are exempt are all fields on an object you can read. The script turns those fields into sentences about what a push would meet. Creating a commit on somebody's default branch to learn a fact that is already published is a change, not a diagnostic.
What about the 404 that does not say Branch not protected?
That one is ambiguous and the script refuses to resolve it. A bare 404 on this path can mean the branch does not exist, the repository does not exist, the repository is private and invisible to this credential, or an App is not installed on it — and GitHub answers 404 rather than 403 for private resources precisely so those cannot be told apart. It is a credential triage rather than a protection finding, and it gets counted in the unknown column where somebody can act on it.
Related field notes
- A 404 that means four different things at once
- The repository itself refuses every write
- What granting admin to a read-only job costs
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Branch protection — GitHub REST API
- Branches — GitHub REST API
- Rules — GitHub REST API
- About protected branches — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.