// Bug Bounty Automation in Ruby · Part 8

Reporting and wiring the bot

The finale. Eight scripts that each rebuild the same client become one Scanner class with a command-line interface, a scope allowlist that makes it physically unable to touch anything but the lab, and a coordinated-disclosure report as its output. Classes, OptionParser, and the ethics that belong in the code itself.

RubyClassesOOPCapstone
6 min read

You’ve found eight bugs, and every part rebuilt the same login-and-request plumbing to do it. That’s the sign it’s time to stop scripting and build a tool: one object that holds the client, runs the checks, and reports what it found. We’ll also wire in the thing a bug-bounty tool needs more than any feature — a scope guardrail that makes it refuse to run anywhere it isn’t allowed.

A class to hold it all

A class bundles data and the methods that act on it. Ours holds a base URL and a list of findings. Start scanner.rb:

#!/usr/bin/env ruby
require 'net/http'
require 'json'
require 'uri'
require 'optparse'

class Scanner
  ALLOWED_HOSTS = %w[127.0.0.1 localhost].freeze

  def initialize(base)
    @base = base
    host = URI(base).host
    unless ALLOWED_HOSTS.include?(host)
      abort "[!] refusing to scan #{host}: not in the scope allowlist"
    end
    @findings = []
  end

initialize runs when you call Scanner.new(...). The @base and @findings are instance variables — each scanner carries its own. The important lines are the guardrail: we pull the host out of the URL and abort unless it’s on ALLOWED_HOSTS. This tool cannot be pointed at a real target, because it quits before sending a single byte. That check is the most important code in the file.

Now the client, as methods on the class:

  def get(path, token = nil)
    uri = URI("#{@base}#{path}")
    req = Net::HTTP::Get.new(uri)
    req['Authorization'] = "Bearer #{token}" if token
    Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
  end

  def login(name)
    res = Net::HTTP.post(URI("#{@base}/login"), { 'name' => name }.to_json,
                         'Content-Type' => 'application/json')
    JSON.parse(res.body)['token']
  end

  def record(title, severity, endpoint, evidence)
    @findings << { title: title, severity: severity, endpoint: endpoint, evidence: evidence }
  end

Same get and login as every part before — but now they live once, on the object. record appends a finding to @findings; each is a hash with everything a report needs.

Folding the checks in

Each bug we found by hand becomes a check method. Here are two from earlier parts, now automated — object-level and function-level authorization:

  def check_bola(token, me_id)
    res = get('/api/users/99', token)
    return unless res.code.to_i == 200
    victim = JSON.parse(res.body)
    return if victim['id'] == me_id
    record('Broken object-level authorization (IDOR)', 'high', 'GET /api/users/{id}',
           "user #{me_id}'s token read user #{victim['id']} (#{victim['name']})")
  end

  def check_bfla(token)
    res = get('/api/admin/stats', token)
    return unless res.code.to_i == 200
    record('Broken function-level authorization', 'high', 'GET /api/admin/stats',
           "a non-admin token received #{res.body.strip}")
  end

Each check does its probe, decides whether the flaw is present, and calls record if so. return unless ... bails early when there’s nothing to report — a check that finds nothing simply adds nothing. Now run orchestrates them and report prints the result:

  def run
    token = login('alice')
    me = JSON.parse(get('/api/me', token).body)
    check_bola(token, me['id'])
    check_bfla(token)
    self
  end

  def report
    puts "# Scan report — #{@base}"
    puts "# #{@findings.size} finding(s)"
    @findings.each_with_index do |f, i|
      puts
      puts "## #{i + 1}. #{f[:title]}  [#{f[:severity].upcase}]"
      puts "Endpoint: #{f[:endpoint]}"
      puts "Evidence: #{f[:evidence]}"
    end
  end
end

run returns self so we can chain .run.report. report walks @findings and prints each in a fixed shape — title, severity, endpoint, evidence — the skeleton of a disclosure.

A real command line

A tool takes arguments. OptionParser (standard library) turns --target URL into a value, with a usage message for free:

options = { target: 'http://127.0.0.1:4567' }
OptionParser.new do |o|
  o.banner = 'Usage: ruby scanner.rb [--target URL]'
  o.on('--target URL', 'Base URL to scan (must be in scope)') { |v| options[:target] = v }
end.parse!

Scanner.new(options[:target]).run.report

Run it with no arguments and it scans the lab:

$ ruby scanner.rb
# Scan report — http://127.0.0.1:4567
# 2 finding(s)

## 1. Broken object-level authorization (IDOR)  [HIGH]
Endpoint: GET /api/users/{id}
Evidence: user 1's token read user 99 (admin)

## 2. Broken function-level authorization  [HIGH]
Endpoint: GET /api/admin/stats
Evidence: a non-admin token received {"users":3,"orders":0,"balances":2500}

That block is a coordinated-disclosure report in miniature: what, how bad, where, and the proof. It’s the raw material you’d expand into a writeup and send to the vendor — and, once they’ve fixed it, publish.

The guardrail earns its keep

Point the same tool at something you don’t own, and it stops itself:

$ ruby scanner.rb --target https://api.example.com
[!] refusing to scan api.example.com: not in the scope allowlist

No request goes out. This is the difference between a tool and a liability: the scope check isn’t a comment reminding you to be careful, it’s code that makes carelessness impossible. When you take this pattern to a real program, the allowlist becomes the exact hosts on your authorization — and nothing else.

Takeaways

  • A class bundles state (@base, @findings) with the methods that use it; initialize sets it up, instance methods act on it, self lets you chain.
  • Fold repeated plumbing into the object once; each hand-found bug becomes a check_* method that probes and calls record.
  • OptionParser gives you a real CLI — flags, defaults, and a usage banner — from a few lines of standard library.
  • Put the ethics in the code: a scope allowlist that aborts on any host you weren’t authorized to test is the single most important line in a scanner. A report is the deliverable; scope is what keeps you on the right side of it.

Exercises

  1. Add check_mass_assignment and check_race from Parts 5 and 6 as methods, so one run covers all four bug classes and reports them together.
  2. Add a --report json option that emits @findings as JSON instead of text, so the output can feed a ticketing system or the diff from Part 7.
  3. Load the allowlist from a --scope host1,host2 flag (still defaulting to localhost). This is how you’d point the tool at one real program’s authorized hosts without ever hardcoding them.

That’s the series: from a first HTTP request to a scoped, self-reporting scanner that automates eight classes of API bug. The techniques are the same ones real bug-bounty automation runs on — the only thing that changes on a live program is that the scope allowlist holds hosts you’ve been given permission to test, and the report ends in a disclosure instead of a lab.

← All parts