Branch protection drifts silently until a force-push to main rewrites history nobody meant to lose. This script uses nothing but Ruby’s stdlib net/http to audit real GitHub repositories for branch protection, force-push settings, required reviews, and Dependabot alerts — testable against any GitHub-compatible endpoint.
Step through the build below:
Branch protection drifts silently. Someone disables “require pull request reviews” to push an urgent hotfix, forgets to turn it back on, and six months later a force-push to main rewrites history nobody meant to lose. Multiply that across fifty repositories and nobody notices until an audit or an incident forces the question.
This script automates that audit against the real GitHub REST API: default-branch protection, whether force-pushes are still allowed, whether pull request review is actually required, and whether Dependabot vulnerability alerts are switched on — all using nothing but Ruby’s net/http standard library, so it runs on a bastion host or a CI runner with zero gems to install.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# repo_governance_audit.rb -- audit a list of GitHub repositories for basic
# governance hygiene: branch protection on the default branch, whether
# force-pushes are still allowed to it, whether pull request review is
# required, and whether Dependabot vulnerability alerts are switched on.
# Built entirely on Ruby's stdlib net/http -- no octokit, no bundler, so it
# drops straight onto a bastion host or a CI runner.
#
# Usage:
# export GITHUB_TOKEN=ghp_xxx # needs repo scope to read branch protection
# ruby repo_governance_audit.rb owner/repo1 owner/repo2
# ruby repo_governance_audit.rb -f repos.txt --json
# ruby repo_governance_audit.rb owner/repo --api-base http://ghe.internal/api/v3
#
# Exit status:
# 0 no CRIT findings
# 1 at least one CRIT finding
# 2 usage / configuration error
#
# Requires: Ruby 3.x, stdlib only (net/http, json, uri, optparse).
require 'net/http'
require 'uri'
require 'json'
require 'optparse'
require 'time'
# ---------------------------------------------------------------------------
# Thin wrapper around Net::HTTP that knows how to talk to the GitHub REST
# API (or an API-compatible stand-in, which is exactly what lets this be
# tested against a local WEBrick server instead of the real api.github.com
# -- see the README's testing notes). Centralizes auth headers, JSON
# decoding, and the one rate-limit retry rule GitHub actually cares about.
# ---------------------------------------------------------------------------
class GitHubClient
RateLimited = Class.new(StandardError)
def initialize(api_base:, token:, user_agent: 'repo-governance-audit-ruby')
@uri_base = URI.parse(api_base)
@token = token
@user_agent = user_agent
end
# Returns [status_code, parsed_json_or_nil, headers]. Retries exactly
# once, after sleeping until X-RateLimit-Reset, if GitHub answers a
# secondary-rate-limit 403.
def get(path, extra_headers = {})
attempts = 0
begin
attempts += 1
uri = @uri_base.dup
uri.path = File.join(@uri_base.path.to_s, path)
req = Net::HTTP::Get.new(uri)
req['Accept'] = 'application/vnd.github+json'
req['User-Agent'] = @user_agent
req['Authorization'] = "Bearer #{@token}" if @token && [email protected]?
extra_headers.each { |k, v| req[k] = v }
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(req)
end
if res.code == '403' && res['X-RateLimit-Remaining'] == '0' && attempts == 1
reset_at = Time.at(res['X-RateLimit-Reset'].to_i)
wait = [reset_at - Time.now, 0].max
warn "rate limited, sleeping #{wait.round}s until #{reset_at}"
sleep([wait, 2].min) # capped in the tutorial/tests; real runs can wait the full window
raise RateLimited
end
body = res.body.to_s.empty? ? nil : (JSON.parse(res.body) rescue nil)
[res.code.to_i, body, res.to_hash]
rescue RateLimited
retry if attempts <= 1
end
end
end
Finding = Struct.new(:severity, :reason, keyword_init: true)
# ---------------------------------------------------------------------------
# Pure classification: given the JSON GitHub already handed back, decide
# what's wrong. No HTTP in here, which is what makes it unit-testable
# without a network at all -- see spec-style checks in the README.
# ---------------------------------------------------------------------------
def classify_repo(repo, protection_status, protection, vuln_alert_status)
return { severity: :ok, findings: [Finding.new(severity: :info, reason: 'archived, skipped')] } if repo['archived']
findings = []
visibility = repo['private'] ? 'private' : 'public'
case protection_status
when 404
sev = repo['private'] ? :warn : :crit
findings << Finding.new(severity: sev, reason: "default branch '#{repo['default_branch']}' has no branch protection (#{visibility} repo)")
when 403
findings << Finding.new(severity: :info, reason: 'could not check branch protection (token lacks admin rights on this repo)')
when 200
if protection.dig('allow_force_pushes', 'enabled')
findings << Finding.new(severity: :crit, reason: 'force-pushes are allowed on the default branch')
end
reviews = protection.dig('required_pull_request_reviews', 'required_approving_review_count')
if reviews.nil? || reviews < 1
findings << Finding.new(severity: :warn, reason: 'no required pull request review count set on the default branch')
end
unless protection.dig('enforce_admins', 'enabled')
findings << Finding.new(severity: :warn, reason: 'branch protection does not apply to repo admins (enforce_admins is off)')
end
if protection.dig('required_status_checks').nil?
findings << Finding.new(severity: :info, reason: 'no required status checks configured on the default branch')
end
end
case vuln_alert_status
when 404
findings << Finding.new(severity: repo['private'] ? :warn : :crit, reason: 'Dependabot vulnerability alerts are disabled')
when 403
findings << Finding.new(severity: :info, reason: 'could not check vulnerability-alert status (token lacks admin rights)')
# 204 == enabled, nothing to report
end
overall = if findings.any? { |f| f.severity == :crit }
:crit
elsif findings.any? { |f| f.severity == :warn }
:warn
else
:ok
end
{ severity: overall, findings: findings }
end
# ---------------------------------------------------------------------------
# One repo, three GitHub API calls, one classification.
# ---------------------------------------------------------------------------
def audit_repo(client, full_name)
owner, repo = full_name.split('/', 2)
raise ArgumentError, "expected owner/repo, got #{full_name.inspect}" unless owner && repo
status, body, = client.get("/repos/#{owner}/#{repo}")
raise "GET /repos/#{owner}/#{repo} -> HTTP #{status}" unless status == 200
result = { repo: full_name, default_branch: body['default_branch'], private: body['private'], archived: body['archived'] }
if body['archived']
merged = classify_repo(body, nil, nil, nil)
else
branch = body['default_branch']
prot_status, prot_body, = client.get("/repos/#{owner}/#{repo}/branches/#{branch}/protection")
vuln_status, _vuln_body, = client.get(
"/repos/#{owner}/#{repo}/vulnerability-alerts",
{ 'Accept' => 'application/vnd.github.dorian-preview+json' }
)
merged = classify_repo(body, prot_status, prot_body, vuln_status)
end
result.merge(merged)
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
options = { api_base: 'https://api.github.com', token: ENV['GITHUB_TOKEN'], json: false, repos_file: nil }
parser = OptionParser.new do |opts|
opts.banner = 'Usage: repo_governance_audit.rb owner/repo [owner/repo ...] [options]'
opts.on('-f', '--file FILE', 'File with one owner/repo per line') { |v| options[:repos_file] = v }
opts.on('--token TOKEN', 'GitHub token (default: $GITHUB_TOKEN)') { |v| options[:token] = v }
opts.on('--api-base URL', "API base URL (default: #{options[:api_base]}; point at a GHES /api/v3 for on-prem)") { |v| options[:api_base] = v }
opts.on('--json', 'Emit a JSON report instead of text') { options[:json] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!(ARGV)
repo_names = ARGV.dup
repo_names.concat(File.readlines(options[:repos_file]).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }) if options[:repos_file]
if repo_names.empty?
warn 'error: no repos given (pass owner/repo arguments or -f repos.txt)'
exit 2
end
client = GitHubClient.new(api_base: options[:api_base], token: options[:token])
results = repo_names.map do |name|
begin
audit_repo(client, name)
rescue StandardError => e
{ repo: name, severity: :crit, findings: [Finding.new(severity: :crit, reason: "audit failed: #{e.class}: #{e.message}")] }
end
end
crit_count = results.count { |r| r[:severity] == :crit }
warn_count = results.count { |r| r[:severity] == :warn }
if options[:json]
puts JSON.pretty_generate(
total: results.size, crit: crit_count, warn: warn_count,
repos: results.map do |r|
{
repo: r[:repo], severity: r[:severity],
findings: r[:findings].map { |f| { severity: f.severity, reason: f.reason } }
}
end
)
else
results.each do |r|
tag = { crit: '[CRIT]', warn: '[WARN]', ok: '[ ok ]' }[r[:severity]]
puts "#{tag} #{r[:repo]}"
r[:findings].each { |f| puts " - #{f.reason}" }
end
puts '---'
puts "#{results.size} repos audited, #{crit_count} CRIT, #{warn_count} WARN"
end
exit(crit_count.positive? ? 1 : 0)
The script splits cleanly into a GitHubClient that only knows how to make authenticated HTTP calls and handle GitHub’s rate-limit contract, and a pure classify_repo function that only knows how to read the JSON GitHub already returned. Nothing in the classifier touches the network — which is exactly what let it be exercised against a local WEBrick stub server instead of the real api.github.com while writing this tutorial.
The --api-base flag is not decoration: pointing it at http://ghe.internal/api/v3 makes this work unmodified against GitHub Enterprise Server, and pointing it at a local stub is how the whole HTTP/JSON path — not just the classification logic — gets real test coverage without needing network access to GitHub at all.
$ export GITHUB_TOKEN=ghp_xxx
$ ruby repo_governance_audit.rb acme/well-governed acme/no-protection acme/force-push-allowed \
acme/archived-repo acme/private-no-protection acme/no-admin-token
[ ok ] acme/well-governed
[CRIT] acme/no-protection
- default branch 'main' has no branch protection (public repo)
- Dependabot vulnerability alerts are disabled
[CRIT] acme/force-push-allowed
- force-pushes are allowed on the default branch
- no required pull request review count set on the default branch
- branch protection does not apply to repo admins (enforce_admins is off)
- no required status checks configured on the default branch
[ ok ] acme/archived-repo
- archived, skipped
[WARN] acme/private-no-protection
- default branch 'main' has no branch protection (private repo)
[ ok ] acme/no-admin-token
- could not check branch protection (token lacks admin rights on this repo)
- could not check vulnerability-alert status (token lacks admin rights)
---
6 repos audited, 2 CRIT, 1 WARN
$ echo $?
1
$ ruby repo_governance_audit.rb acme/well-governed acme/no-protection --json
{
"total": 2, "crit": 1, "warn": 0,
"repos": [
{ "repo": "acme/well-governed", "severity": "ok", "findings": [] },
{ "repo": "acme/no-protection", "severity": "crit", "findings": [
{ "severity": "crit", "reason": "default branch 'main' has no branch protection (public repo)" },
{ "severity": "crit", "reason": "Dependabot vulnerability alerts are disabled" }
] }
]
}
# verified against a local WEBrick stub implementing the same 3 endpoints (repo info, branch
# protection, vulnerability-alerts) real api.github.com exposes, so the HTTP/JSON handling,
# classification, and exit codes were all exercised end-to-end -- see the README's testing notes.
Full script + README on GitHub: ruby-devops-toolkit/repo-governance-audit
Governance settings drift, and nobody watches them
GitHub’s branch protection, required reviews, and Dependabot alerts are all per-repository settings that anyone with admin rights can quietly change — often for a legitimate reason in the moment, and often never reverted. A security or platform team responsible for fifty-plus repositories cannot click through each one’s Settings tab every week. This script turns that manual click-through into three REST calls per repo and a same-severity report every other script in this toolkit uses: text for a human, --json for a pipeline, and a non-zero exit code the moment something CRIT shows up.
What you need before running this
- Ruby 3.x, stdlib only —
net/http,uri,json,optparseall ship with Ruby, nothing tobundle install. - A GitHub personal access token with
reposcope (classic) or equivalent fine-grained permissions, set as$GITHUB_TOKENor passed with--token. Reading branch protection and vulnerability-alert status requires admin rights on each repo — without that, the script still runs, it just reports “could not check” for those two items instead of crashing. - Network access to api.github.com (or your GitHub Enterprise Server’s
/api/v3, via--api-base).
repo_governance_audit.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# repo_governance_audit.rb -- audit a list of GitHub repositories for basic
# governance hygiene: branch protection on the default branch, whether
# force-pushes are still allowed to it, whether pull request review is
# required, and whether Dependabot vulnerability alerts are switched on.
# Built entirely on Ruby's stdlib net/http -- no octokit, no bundler, so it
# drops straight onto a bastion host or a CI runner.
#
# Usage:
# export GITHUB_TOKEN=ghp_xxx # needs repo scope to read branch protection
# ruby repo_governance_audit.rb owner/repo1 owner/repo2
# ruby repo_governance_audit.rb -f repos.txt --json
# ruby repo_governance_audit.rb owner/repo --api-base http://ghe.internal/api/v3
#
# Exit status:
# 0 no CRIT findings
# 1 at least one CRIT finding
# 2 usage / configuration error
#
# Requires: Ruby 3.x, stdlib only (net/http, json, uri, optparse).
require 'net/http'
require 'uri'
require 'json'
require 'optparse'
require 'time'
# ---------------------------------------------------------------------------
# Thin wrapper around Net::HTTP that knows how to talk to the GitHub REST
# API (or an API-compatible stand-in, which is exactly what lets this be
# tested against a local WEBrick server instead of the real api.github.com
# -- see the README's testing notes). Centralizes auth headers, JSON
# decoding, and the one rate-limit retry rule GitHub actually cares about.
# ---------------------------------------------------------------------------
class GitHubClient
RateLimited = Class.new(StandardError)
def initialize(api_base:, token:, user_agent: 'repo-governance-audit-ruby')
@uri_base = URI.parse(api_base)
@token = token
@user_agent = user_agent
end
# Returns [status_code, parsed_json_or_nil, headers]. Retries exactly
# once, after sleeping until X-RateLimit-Reset, if GitHub answers a
# secondary-rate-limit 403.
def get(path, extra_headers = {})
attempts = 0
begin
attempts += 1
uri = @uri_base.dup
uri.path = File.join(@uri_base.path.to_s, path)
req = Net::HTTP::Get.new(uri)
req['Accept'] = 'application/vnd.github+json'
req['User-Agent'] = @user_agent
req['Authorization'] = "Bearer #{@token}" if @token && [email protected]?
extra_headers.each { |k, v| req[k] = v }
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(req)
end
if res.code == '403' && res['X-RateLimit-Remaining'] == '0' && attempts == 1
reset_at = Time.at(res['X-RateLimit-Reset'].to_i)
wait = [reset_at - Time.now, 0].max
warn "rate limited, sleeping #{wait.round}s until #{reset_at}"
sleep([wait, 2].min) # capped in the tutorial/tests; real runs can wait the full window
raise RateLimited
end
body = res.body.to_s.empty? ? nil : (JSON.parse(res.body) rescue nil)
[res.code.to_i, body, res.to_hash]
rescue RateLimited
retry if attempts <= 1
end
end
end
Finding = Struct.new(:severity, :reason, keyword_init: true)
# ---------------------------------------------------------------------------
# Pure classification: given the JSON GitHub already handed back, decide
# what's wrong. No HTTP in here, which is what makes it unit-testable
# without a network at all -- see spec-style checks in the README.
# ---------------------------------------------------------------------------
def classify_repo(repo, protection_status, protection, vuln_alert_status)
return { severity: :ok, findings: [Finding.new(severity: :info, reason: 'archived, skipped')] } if repo['archived']
findings = []
visibility = repo['private'] ? 'private' : 'public'
case protection_status
when 404
sev = repo['private'] ? :warn : :crit
findings << Finding.new(severity: sev, reason: "default branch '#{repo['default_branch']}' has no branch protection (#{visibility} repo)")
when 403
findings << Finding.new(severity: :info, reason: 'could not check branch protection (token lacks admin rights on this repo)')
when 200
if protection.dig('allow_force_pushes', 'enabled')
findings << Finding.new(severity: :crit, reason: 'force-pushes are allowed on the default branch')
end
reviews = protection.dig('required_pull_request_reviews', 'required_approving_review_count')
if reviews.nil? || reviews < 1
findings << Finding.new(severity: :warn, reason: 'no required pull request review count set on the default branch')
end
unless protection.dig('enforce_admins', 'enabled')
findings << Finding.new(severity: :warn, reason: 'branch protection does not apply to repo admins (enforce_admins is off)')
end
if protection.dig('required_status_checks').nil?
findings << Finding.new(severity: :info, reason: 'no required status checks configured on the default branch')
end
end
case vuln_alert_status
when 404
findings << Finding.new(severity: repo['private'] ? :warn : :crit, reason: 'Dependabot vulnerability alerts are disabled')
when 403
findings << Finding.new(severity: :info, reason: 'could not check vulnerability-alert status (token lacks admin rights)')
# 204 == enabled, nothing to report
end
overall = if findings.any? { |f| f.severity == :crit }
:crit
elsif findings.any? { |f| f.severity == :warn }
:warn
else
:ok
end
{ severity: overall, findings: findings }
end
# ---------------------------------------------------------------------------
# One repo, three GitHub API calls, one classification.
# ---------------------------------------------------------------------------
def audit_repo(client, full_name)
owner, repo = full_name.split('/', 2)
raise ArgumentError, "expected owner/repo, got #{full_name.inspect}" unless owner && repo
status, body, = client.get("/repos/#{owner}/#{repo}")
raise "GET /repos/#{owner}/#{repo} -> HTTP #{status}" unless status == 200
result = { repo: full_name, default_branch: body['default_branch'], private: body['private'], archived: body['archived'] }
if body['archived']
merged = classify_repo(body, nil, nil, nil)
else
branch = body['default_branch']
prot_status, prot_body, = client.get("/repos/#{owner}/#{repo}/branches/#{branch}/protection")
vuln_status, _vuln_body, = client.get(
"/repos/#{owner}/#{repo}/vulnerability-alerts",
{ 'Accept' => 'application/vnd.github.dorian-preview+json' }
)
merged = classify_repo(body, prot_status, prot_body, vuln_status)
end
result.merge(merged)
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
options = { api_base: 'https://api.github.com', token: ENV['GITHUB_TOKEN'], json: false, repos_file: nil }
parser = OptionParser.new do |opts|
opts.banner = 'Usage: repo_governance_audit.rb owner/repo [owner/repo ...] [options]'
opts.on('-f', '--file FILE', 'File with one owner/repo per line') { |v| options[:repos_file] = v }
opts.on('--token TOKEN', 'GitHub token (default: $GITHUB_TOKEN)') { |v| options[:token] = v }
opts.on('--api-base URL', "API base URL (default: #{options[:api_base]}; point at a GHES /api/v3 for on-prem)") { |v| options[:api_base] = v }
opts.on('--json', 'Emit a JSON report instead of text') { options[:json] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!(ARGV)
repo_names = ARGV.dup
repo_names.concat(File.readlines(options[:repos_file]).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }) if options[:repos_file]
if repo_names.empty?
warn 'error: no repos given (pass owner/repo arguments or -f repos.txt)'
exit 2
end
client = GitHubClient.new(api_base: options[:api_base], token: options[:token])
results = repo_names.map do |name|
begin
audit_repo(client, name)
rescue StandardError => e
{ repo: name, severity: :crit, findings: [Finding.new(severity: :crit, reason: "audit failed: #{e.class}: #{e.message}")] }
end
end
crit_count = results.count { |r| r[:severity] == :crit }
warn_count = results.count { |r| r[:severity] == :warn }
if options[:json]
puts JSON.pretty_generate(
total: results.size, crit: crit_count, warn: warn_count,
repos: results.map do |r|
{
repo: r[:repo], severity: r[:severity],
findings: r[:findings].map { |f| { severity: f.severity, reason: f.reason } }
}
end
)
else
results.each do |r|
tag = { crit: '[CRIT]', warn: '[WARN]', ok: '[ ok ]' }[r[:severity]]
puts "#{tag} #{r[:repo]}"
r[:findings].each { |f| puts " - #{f.reason}" }
end
puts '---'
puts "#{results.size} repos audited, #{crit_count} CRIT, #{warn_count} WARN"
end
exit(crit_count.positive? ? 1 : 0)
How it actually works
One client class owns every GitHub-specific detail
GitHubClient#get centralizes exactly three things every call needs: the Authorization: Bearer header when a token is present, the Accept: application/vnd.github+json header GitHub expects, and secondary-rate-limit handling — if GitHub answers with 403 and X-RateLimit-Remaining: 0, it sleeps until the reported reset time and retries exactly once rather than hammering the API or silently giving up.
Three calls per repo, one classification
audit_repo makes up to three requests: the repo’s basic info (to get the default branch and archived/private flags), the default branch’s protection settings, and the vulnerability-alerts endpoint (which GitHub signals purely through HTTP status: 204 means enabled, 404 means disabled — there is no response body to parse). Archived repos skip the last two calls entirely since a mothballed repo’s branch protection is not worth spending API quota on.
Why classify_repo takes plain values, not HTTP responses
classify_repo(repo, protection_status, protection, vuln_alert_status) accepts a decoded JSON hash and a couple of integers — never a Net::HTTPResponse. That is what let this function get exercised directly against hand-built fixtures representing a well-governed repo, an unprotected public repo, a repo with force-push still enabled, and a repo where the token lacks admin rights, without any network mocking at all.
Public vs. private changes the severity, not the check
A public repo with no branch protection is CRIT — anyone on the internet can see the repo and a compromised contributor account could rewrite its history. The same finding on a private repo is only WARN, since the blast radius is limited to people your org already trusts with repo access. The check is identical either way; only the severity mapping changes.
What a real run looks like
When it doesn't work
- Every repo reports “could not check branch protection” — your token doesn’t have admin rights on those repos. Branch protection and vulnerability-alert status are both admin-only reads on GitHub’s API; a token with plain
reporead access will get403on both. - Rate limited on a large repo list — GitHub’s unauthenticated rate limit is 60 requests/hour and the authenticated limit is 5,000/hour; always pass a token, even for public repos, or you’ll exhaust the unauthenticated budget after ~20 repos (3 calls each).
- 404 on the very first call — double-check the
owner/repospelling; the script surfaces this as an “audit failed” CRIT finding for that repo rather than crashing the whole run, so one typo doesn’t take down the batch. - Testing without touching the real API — this script was verified against a small local WEBrick server implementing the same three endpoints, with fixtures for a well-governed repo, an unprotected public repo, a force-push-enabled repo, an archived repo, a private unprotected repo, and a no-admin-token repo — real HTTP requests and JSON responses, just not against api.github.com.
Where to take this next
- Org-wide sweep: page through
GET /orgs/:org/reposinstead of a static repo list, so the audit covers every repo in an organization automatically. - Secret scanning & code scanning status: add the equivalent GitHub Advanced Security endpoints alongside vulnerability alerts, for orgs with that add-on enabled.
- Auto-remediation: behind an explicit
--fixflag,PUTa minimum branch-protection policy onto repos that fail the audit, instead of only reporting. - Historical trending: store each run’s JSON output and diff it against the previous run to flag newly non-compliant repos instead of re-reporting the same long-standing exceptions every time.