Branch Protection and Code Review Security at Scale
Problem
Code review is the last human gate before code reaches production. Branch protection rules enforce that gate: they prevent direct pushes to protected branches, require review approvals, and block merges when required status checks fail. Without them, a developer — or an attacker with developer credentials — can push directly to main and bypass all CI security checks.
At scale, branch protection has additional failure modes:
- Inconsistent enforcement. Rules are configured per-repository by repository owners. New repos created by developers often have no protection. A supply chain attacker targets the least-protected repository.
- Admin bypass. Repository admins are often exempt from protection rules (
enforce admins: false). An attacker who compromises an admin account pushes directly tomain. - Review rubber-stamping. Approval requirements exist, but the same person can approve their own team’s PRs, or approvals are given without reading the diff. Stale reviews (approved before subsequent commits) persist.
- CODEOWNERS not enforced. Security-critical files (IAM policies, Terraform state, pipeline configuration) don’t have mandatory reviewers. A developer unfamiliar with security implications merges a change.
- Status checks not required. CI passes optional security scans but they’re not blocking. PRs merge despite CodeQL findings.
Target systems: GitHub (Enterprise Cloud and Server 3.9+), GitLab 16+, Bitbucket Cloud; GitHub Rulesets API (GitHub Enterprise); CODEOWNERS syntax; gh CLI for bulk configuration.
Threat Model
- Adversary 1 — Direct push to main: A developer account is compromised. The attacker pushes malicious code directly to the default branch, bypassing CI and review. Without branch protection, this deploys to production immediately.
- Adversary 2 — Admin account takeover + branch protection bypass: A repository admin account is compromised. The attacker disables branch protection, merges a backdoored commit, re-enables protection. Without
enforce admins: true, this is silent. - Adversary 3 — Self-approval on security-critical change: A developer modifies a security policy file (
terraform/iam.tf,.github/workflows/deploy.yml) and approves their own PR (if they have another account), or gets a colleague to approve without understanding the change. Without CODEOWNERS pointing a security team to the review, nobody with security expertise sees it. - Adversary 4 — Stale approval bypass: A PR is reviewed and approved. The developer pushes additional commits containing malicious changes. The stale approval persists. Without stale review dismissal, the PR merges with the malicious changes unchecked.
- Adversary 5 — New unprotected repository: A developer creates a new repository for a service that will be deployed to production. No protection rules are applied by default. An attacker targets it.
- Access level: Adversary 1 has developer credentials. Adversaries 2 and 3 have developer or admin credentials. Adversary 4 has developer credentials and an approved PR. Adversary 5 needs only network access to push code to the unprotected repo.
- Objective: Introduce malicious code to the production deployment pipeline without triggering review controls.
- Blast radius: A direct push to
mainwith no review gate can deploy arbitrary code to production within minutes of the push. CODEOWNERS violations lead to unreviewed changes to security-critical infrastructure.
Configuration
Step 1: GitHub Rulesets for Org-Wide Enforcement
GitHub Rulesets (available in Enterprise and organisation-level settings) apply branch protection rules to all repositories in an organisation simultaneously, including new ones created after the ruleset is configured.
# Create an org-level ruleset via the GitHub API.
gh api \
--method POST \
/orgs/{org}/rulesets \
--input - <<'EOF'
{
"name": "default-branch-protection",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
},
"repository_name": {
"include": ["~ALL"],
"exclude": ["sandbox-*", "test-*"]
}
},
"rules": [
{"type": "deletion"},
{"type": "non_fast_forward"},
{"type": "required_linear_history"},
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": true,
"allowed_merge_methods": ["squash", "merge"]
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{"context": "CodeQL / Analyze (python)", "integration_id": 0},
{"context": "CodeQL / Analyze (javascript)", "integration_id": 0},
{"context": "security-scan", "integration_id": 0}
]
}
},
{"type": "required_signatures"}
],
"bypass_actors": [
{
"actor_id": 1,
"actor_type": "OrganizationAdmin",
"bypass_mode": "pull_request"
}
]
}
EOF
Key rules in the ruleset:
"type": "deletion"— prevents branch deletion."type": "non_fast_forward"— prevents force-pushes (rewrites history)."dismiss_stale_reviews_on_push": true— removes approvals when new commits are pushed (closes stale approval bypass)."require_last_push_approval": true— the person who pushed the last commit cannot be the approver."require_code_owner_review": true— CODEOWNERS-defined owners must approve changes to their paths."required_signatures"— commits must be GPG-signed.bypass_actorswithbypass_mode: pull_request— admins can bypass only via PRs, not direct push.
Step 2: CODEOWNERS for Security-Critical Paths
# .github/CODEOWNERS
# Format: <pattern> <owner> [<owner2> ...]
# Owners are GitHub usernames, team names (@org/team), or email addresses.
# Default: any change requires review from the platform team.
* @myorg/platform-team
# IAM and access control changes: security team required.
terraform/iam/ @myorg/security-team
terraform/iam/** @myorg/security-team
.github/workflows/ @myorg/security-team
.github/CODEOWNERS @myorg/security-team
# Deployment configuration: SRE team required.
kubernetes/ @myorg/sre-team
helm/ @myorg/sre-team
Makefile @myorg/sre-team
# Database schema changes: DBA team required.
db/migrations/ @myorg/dba-team
# Security policies (Kyverno, OPA): security team required.
policies/ @myorg/security-team
# CI/CD pipeline configuration: security team + platform team.
.github/workflows/deploy.yml @myorg/security-team @myorg/platform-team
.github/workflows/release.yml @myorg/security-team @myorg/platform-team
# Package manifests: security scan required (enforced by CI, not CODEOWNERS reviewer).
package.json @myorg/security-team
requirements.txt @myorg/security-team
go.mod @myorg/security-team
CODEOWNERS is only effective when require_code_owner_review: true is set in branch protection. Without that setting, CODEOWNERS is informational only.
Step 3: Enforce Admin Compliance
The enforce admins: true equivalent in Rulesets is not exempting org admins from the bypass rules:
# Check if any repositories have admins exempt from protection.
gh api /orgs/{org}/repos --paginate --jq '.[].name' | while read repo; do
rule=$(gh api /repos/{org}/$repo/branches/main/protection 2>/dev/null | \
jq -r '.enforce_admins.enabled // "not-protected"')
if [[ "$rule" == "false" || "$rule" == "not-protected" ]]; then
echo "RISK: $repo admin bypass enabled"
fi
done
With Rulesets, bypass_actors of type OrganizationAdmin with bypass_mode: pull_request means admins must still submit PRs — they just don’t need approvals. This is a reasonable emergency access pattern while closing the direct-push bypass.
Step 4: Commit Signing Enforcement
Require signed commits to verify developer identity:
# Enable commit signing requirement for the main branch.
# Already included in the Ruleset above as "required_signatures".
# Developers: configure GPG signing.
git config --global commit.gpgsign true
git config --global user.signingkey <key-id>
# Or use SSH signing (GitHub supports since 2022).
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
# Add the signing key to GitHub.
gh ssh-key add ~/.ssh/id_ed25519.pub --type signing
In CI pipelines, sign commits using a bot GPG key:
# .github/workflows/auto-pr.yml
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.BOT_GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.BOT_GPG_PASSPHRASE }}
git_user_signingkey: true
git_commit_gpgsign: true
Step 5: Automate Compliance Checks Across Repos
Run nightly to detect drift from the expected ruleset:
#!/bin/bash
# check-branch-protection-compliance.sh
# Checks that all production repositories meet protection standards.
REQUIRED_APPROVALS=1
REQUIRED_CHECKS=("CodeQL / Analyze (python)" "security-scan")
VIOLATIONS=()
repos=$(gh api /orgs/{org}/repos --paginate \
--jq '.[] | select(.archived == false and .fork == false) | .name')
for repo in $repos; do
protection=$(gh api /repos/{org}/$repo/branches/main/protection 2>/dev/null)
if [ -z "$protection" ]; then
VIOLATIONS+=("$repo: no branch protection on main")
continue
fi
# Check required reviews.
required_reviews=$(echo $protection | jq -r \
'.required_pull_request_reviews.required_approving_review_count // 0')
if [[ $required_reviews -lt $REQUIRED_APPROVALS ]]; then
VIOLATIONS+=("$repo: required approvals = $required_reviews (need $REQUIRED_APPROVALS)")
fi
# Check enforce admins.
enforce_admins=$(echo $protection | jq -r '.enforce_admins.enabled // false')
if [[ $enforce_admins == "false" ]]; then
VIOLATIONS+=("$repo: admins not subject to protection rules")
fi
# Check stale review dismissal.
dismiss_stale=$(echo $protection | jq -r \
'.required_pull_request_reviews.dismiss_stale_reviews // false')
if [[ $dismiss_stale == "false" ]]; then
VIOLATIONS+=("$repo: stale reviews not dismissed on push")
fi
done
if [ ${#VIOLATIONS[@]} -gt 0 ]; then
echo "BRANCH PROTECTION VIOLATIONS:"
for v in "${VIOLATIONS[@]}"; do
echo " - $v"
done
exit 1
fi
echo "All repositories compliant."
Run in CI on a schedule:
on:
schedule:
- cron: "0 9 * * *"
jobs:
compliance-check:
runs-on: ubuntu-latest
steps:
- run: ./scripts/check-branch-protection-compliance.sh
env:
GH_TOKEN: ${{ secrets.ORG_READ_TOKEN }}
Step 6: PR Size and Complexity Limits
Large PRs are rarely reviewed carefully. Enforce size limits:
# .github/workflows/pr-size-check.yml
name: PR size check
on: pull_request
jobs:
size-check:
runs-on: ubuntu-latest
steps:
- name: Check PR size
uses: CodelyTV/pr-size-labeler@v1
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
xs_label: 'size/XS'
xs_max_size: 10
s_label: 'size/S'
s_max_size: 100
m_label: 'size/M'
m_max_size: 500
l_label: 'size/L'
l_max_size: 1000
xl_label: 'size/XL'
fail_if_xl: true # Block XL PRs; force splitting.
message_if_xl: "This PR is too large for effective review. Please split it into smaller changes."
For security-critical paths (from CODEOWNERS), require two reviewers on large changes:
# Second required review for large security-policy changes.
# In Ruleset: set required_approving_review_count: 2 for the policies/ path.
# Or: use a separate ruleset with higher review count for security-critical paths.
Step 7: Telemetry
branch_protection_violation_total{repo, violation_type} counter
pr_merged_without_required_review_total{repo} counter
codeowner_review_required_total{repo, path_owner} counter
codeowner_review_bypassed_total{repo} counter
commit_unsigned_total{repo} counter
direct_push_to_protected_branch_total{repo, user} counter
Alert on:
branch_protection_violation_total— a repo is out of compliance; fix immediately.direct_push_to_protected_branch_total— this should never happen with Rulesets; if it does, admin bypass was used without proper process.codeowner_review_bypassed_total— a merge happened without CODEOWNERS review; investigate.
Expected Behaviour
| Signal | No branch protection | Hardened branch protection |
|---|---|---|
Developer pushes to main directly |
Succeeds | Blocked; PR required |
| Admin force-pushes to override | Succeeds silently | Blocked (ruleset bypass_mode=pull_request) |
| PR approved, then new malicious commit added | Old approval persists; merges | Stale review dismissed; re-review required |
| Security-critical file changed without security team review | Merges without expertise | CODEOWNERS blocks merge until required owner approves |
| New repository created | No protection by default | Org Ruleset applies automatically |
| Unsigned commits | Accepted | Blocked by required_signatures rule |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Org-level Rulesets | Consistent enforcement; covers new repos | Some repos have legitimate exceptions | Use repo-name conditions in Ruleset to exclude sandbox/test repos explicitly. |
enforce admins: true |
No bypass path for admins | Slows legitimate emergency changes | Allow admin bypass via PR only (bypass_mode: pull_request); document emergency process. |
| Stale review dismissal | Prevents post-approval injection | Developers must re-request review after fixing feedback | Expected behaviour; the review covers the entire PR including fixes. |
| Commit signing | Verified developer identity | Setup friction; CI pipelines need signing keys | Provide setup instructions and a pre-commit hook; automate for CI. |
| Required CODEOWNERS review | Security expertise on critical paths | CODEOWNERS team may become a bottleneck | Keep CODEOWNERS teams small; set response SLAs; use teams not individuals. |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Ruleset misconfigured excludes prod repos | Production repos unprotected | Compliance check script detects; branch_protection_violation_total rises |
Fix ruleset condition; verify all production repos are included. |
| CODEOWNERS owner left the organisation | PRs to security-critical paths can never merge | PRs show “Review required from @departed-user” | Update CODEOWNERS to replace with a team or current individual; use teams over individuals. |
| Required status check renamed in CI | All PRs blocked; check name doesn’t match | PRs show “Waiting for required status: old-check-name” | Update the required_status_checks in the Ruleset to match the new check name. |
| Branch protection bypass during incident | Incident responder needs direct push | Detected via direct_push_to_protected_branch_total |
Document the incident; create a break-glass procedure; post-incident review. |
| PR size limit breaks a legitimate large refactor | Developer cannot merge large but legitimate PR | Build fails with size label check failure | CODEOWNERS team reviews and approves an exception; or split the refactor into logical chunks. |