// 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.
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;initializesets it up, instance methods act on it,selflets you chain. - Fold repeated plumbing into the object once; each hand-found bug becomes a
check_*method that probes and callsrecord. OptionParsergives 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
- Add
check_mass_assignmentandcheck_racefrom Parts 5 and 6 as methods, so oneruncovers all four bug classes and reports them together. - Add a
--report jsonoption that emits@findingsas JSON instead of text, so the output can feed a ticketing system or the diff from Part 7. - Load the allowlist from a
--scope host1,host2flag (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.