the shed // ruby for devops

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:

repo_governance_audit.rb

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.

Get the code

Full script + README on GitHub: ruby-devops-toolkit/repo-governance-audit

the problem

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.

prerequisites

What you need before running this

requirements
  • Ruby 3.x, stdlib only — net/http, uri, json, optparse all ship with Ruby, nothing to bundle install.
  • A GitHub personal access token with repo scope (classic) or equivalent fine-grained permissions, set as $GITHUB_TOKEN or 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).
the full script

repo_governance_audit.rb

repo_governance_audit.rbruby
#!/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)
walkthrough

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.

example output

What a real run looks like

repo_governance_audit.rb across 6 repos
[ 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
[WARN] acme/private-no-protection
– default branch 'main' has no branch protection (private repo)
6 repos audited, 2 CRIT, 1 WARN
GitHub Repo Governance Audit workflow diagram

Three REST calls per repo feeding a pure classify_repo function and a CI-friendly exit code
troubleshooting

When it doesn't work

common issues
  • 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 repo read access will get 403 on 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/repo spelling; 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.
extending it

Where to take this next

ideas
  • Org-wide sweep: page through GET /orgs/:org/repos instead 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 --fix flag, PUT a 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.