audit-labs/audit-tools

A collection of scripts, queries, and other goodies you can use in an audit.

clone: git clone https://gitbay.org/audit-labs/audit-tools.git

v1.0.0: applications/github/collectors/branch_protections.py · raw

  1"""
  2Collect branch protection across all repos in an org.
  3
  4Protection can come from two independent systems:
  5
  6- **Classic branch protection** (``/branches/{branch}/protection``)
  7- **Rulesets** (org- or repo-level) — a branch protected only by a ruleset does
  8  not appear in the classic endpoint at all.
  9
 10Both are checked per branch and merged, so ruleset-only protection is no longer
 11reported as unprotected. ``protection_source`` records where the protection
 12comes from.
 13"""
 14
 15import sys
 16
 17import requests
 18
 19from .api import paginate
 20
 21_NO_PROTECTION = {
 22    "required_reviews": None,
 23    "dismiss_stale_reviews": None,
 24    "require_code_owner_reviews": None,
 25    "required_status_checks": None,
 26    "enforce_admins": None,
 27    "restrictions": None,
 28}
 29
 30
 31def branch_protections(org, cfg):
 32    """For each repo, return protection settings per branch (classic + ruleset).
 33
 34    Repos whose branches endpoint returns 403/404 are skipped with a warning.
 35    """
 36    repos = paginate(f"https://api.github.com/orgs/{org}/repos", cfg)
 37    rows = []
 38
 39    for repo in repos:
 40        repo_name = repo["name"]
 41        try:
 42            branches = paginate(
 43                f"https://api.github.com/repos/{org}/{repo_name}/branches", cfg
 44            )
 45        except requests.HTTPError as e:
 46            if e.response is not None and e.response.status_code in (403, 404):
 47                print(
 48                    f"  Skipping {repo_name}: branches endpoint returned "
 49                    f"{e.response.status_code}",
 50                    file=sys.stderr,
 51                )
 52                continue
 53            raise
 54
 55        for branch in branches:
 56            rows.append(_branch_row(org, repo_name, branch["name"], cfg))
 57
 58    return rows
 59
 60
 61def _branch_row(org, repo, branch, cfg):
 62    classic = _classic_protection(org, repo, branch, cfg)
 63    ruleset = _ruleset_protection(org, repo, branch, cfg)
 64
 65    if classic and ruleset:
 66        source = "branch protection + ruleset"
 67    elif classic:
 68        source = "branch protection"
 69    elif ruleset:
 70        source = "ruleset"
 71    else:
 72        source = ""
 73
 74    # Prefer classic values where present, otherwise fall back to ruleset.
 75    details = classic or ruleset or _NO_PROTECTION
 76    return {
 77        "repo": repo,
 78        "branch": branch,
 79        "protected": bool(classic or ruleset),
 80        "protection_source": source,
 81        **details,
 82    }
 83
 84
 85def _classic_protection(org, repo, branch, cfg):
 86    """Return classic branch-protection details, or None if not protected."""
 87    url = f"https://api.github.com/repos/{org}/{repo}/branches/{branch}/protection"
 88    resp = requests.get(url, headers=cfg["headers"], timeout=cfg["timeout"])
 89    if resp.status_code in (403, 404):
 90        return None
 91    resp.raise_for_status()
 92    p = resp.json()
 93    reviews = p.get("required_pull_request_reviews", {})
 94    checks = p.get("required_status_checks", {})
 95    return {
 96        "required_reviews": reviews.get("required_approving_review_count"),
 97        "dismiss_stale_reviews": reviews.get("dismiss_stale_reviews"),
 98        "require_code_owner_reviews": reviews.get("require_code_owner_reviews"),
 99        "required_status_checks": ", ".join(checks.get("contexts", [])) or None,
100        "enforce_admins": p.get("enforce_admins", {}).get("enabled"),
101        "restrictions": bool(p.get("restrictions")),
102    }
103
104
105def _ruleset_protection(org, repo, branch, cfg):
106    """Return protection derived from the rulesets active on a branch, or None.
107
108    The per-branch rules endpoint aggregates the rules enforced on the branch
109    from every applicable org- and repo-level ruleset.
110    """
111    url = f"https://api.github.com/repos/{org}/{repo}/rules/branches/{branch}"
112    try:
113        rules = paginate(url, cfg)
114    except requests.HTTPError as e:
115        if e.response is not None and e.response.status_code in (403, 404):
116            return None
117        raise
118    if not rules:
119        return None
120
121    params = {}
122    for rule in rules:
123        params.setdefault(rule.get("type"), rule.get("parameters") or {})
124
125    pull_request = params.get("pull_request", {})
126    status_checks = params.get("required_status_checks", {})
127    contexts = [
128        c.get("context", "") for c in status_checks.get("required_status_checks", [])
129    ]
130    return {
131        "required_reviews": pull_request.get("required_approving_review_count"),
132        "dismiss_stale_reviews": pull_request.get("dismiss_stale_reviews_on_push"),
133        "require_code_owner_reviews": pull_request.get("require_code_owner_review"),
134        "required_status_checks": ", ".join(contexts) or None,
135        # Rulesets model admin enforcement and push restrictions via bypass
136        # actors, which the per-branch rules endpoint does not return.
137        "enforce_admins": None,
138        "restrictions": None,
139    }