// Bug Bounty Automation in Ruby · Part 2

Mapping the API

A spec tells you what an API wants you to see. We parse it into an inventory, then probe a wordlist to find the endpoints it left out — learning arrays, hashes, iteration, and the difference between a 404 and a 401 along the way.

RubyReconArraysHashes
4 min read

Part 1 left us logging in and reading a profile. Before hunting bugs you need a map: every endpoint the API exposes, not just the ones it advertises. This part builds that map two ways — from the published spec, then from probing the server directly — and teaches you Ruby’s core collection tools doing it.

Start a new file, map.rb, with a tiny GET client. Net::HTTP.get_response does the whole request in one call, which is all we need for recon:

require 'net/http'
require 'json'
require 'uri'

BASE = 'http://127.0.0.1:4567'

def get(path)
  Net::HTTP.get_response(URI("#{BASE}#{path}"))
end

Reading the spec

Most APIs ship a machine-readable spec — OpenAPI/Swagger — listing their endpoints. Ours serves one at /openapi.json. Pull it and turn it into a flat list of [method, path] pairs:

spec = JSON.parse(get('/openapi.json').body)

documented = spec['paths'].flat_map do |path, methods|
  methods.map { |m| [m.upcase, path] }
end

documented.each do |method, path|
  puts "#{method.ljust(4)} #{path}"
end

spec['paths'] is a hash of path => [methods]. flat_map runs the block for each pair and flattens the results into one list — without it you’d get an array of arrays. Inside, methods.map { |m| [m.upcase, path] } turns each method string into a [method, path] pair. ljust(4) pads the method so the paths line up. Run it:

$ ruby map.rb
POST /login
GET  /api/me
GET  /api/users/{id}
GET  /api/markets
GET  /api/markets/{id}
POST /api/orders
GET  /api/search
POST /api/redeem

Eight documented endpoints. That’s what the API admits to.

Probing for the rest

Specs lie by omission. Debug routes, admin panels, and forgotten endpoints rarely make it into the published paths — which is exactly why they’re worth finding. The technique is content discovery: take a wordlist of likely paths and ask the server about each one. Add a list and a loop:

WORDLIST = %w[
  api/me api/users/1 api/markets api/orders api/admin
  api/admin/stats api/admin/users api/debug api/config
  api/redeem api/search api/health backup .git/config
]

WORDLIST.each do |word|
  code = get("/#{word}").code.to_i
  next if code == 404
  puts "#{code}  /#{word}"
end

%w[...] is shorthand for an array of strings — no quotes or commas needed. next if code == 404 skips the rest of the loop body for that iteration, so we only print paths that exist. Run it:

$ ruby map.rb
...
401  /api/me
401  /api/users/1
200  /api/markets
401  /api/admin/stats
200  /api/debug
200  /api/search

Read those status codes carefully — they’re the whole point:

  • 404 means nothing here; we skipped those.
  • 200 means here and open. /api/debug is a 200 and it isn’t in the spec. That’s the find: an undocumented endpoint answering freely.
  • 401 means here but needs auth. A 401 is not a dead end — it’s a confirmed endpoint you just need a token for. /api/admin/stats is a 401 and also absent from the spec: an admin route someone forgot to document.

Three of these — /api/me, /api/users/1, /api/admin/stats — are 401 because get sends no token; each is a real endpoint waiting for one, not a wall. A hunter who treats every non-200 as “nothing” walks right past the admin panel.

Saving the map

Later parts re-run against this API, and in Part 7 we’ll diff one run against the next. So write the inventory to disk as JSON:

inventory = documented.map { |method, path| { 'method' => method, 'path' => path } }
File.write('inventory.json', JSON.pretty_generate(inventory))
puts "[+] wrote #{inventory.size} endpoints to inventory.json"

JSON.pretty_generate serializes with indentation so the file is readable, and File.write drops it to disk in one call. That file is the baseline our scanner will grow around.

Takeaways

  • flat_map maps and flattens — ideal for turning a hash of path => [methods] into a flat list of [method, path] pairs.
  • %w[a b c] builds a string array cheaply; each iterates; next if ... skips one iteration.
  • In recon, status codes are signal: 404 is empty, 200 is open, 401 is “exists, needs auth.” The undocumented 200s and 401s are where the bugs hide.
  • File.write + JSON.pretty_generate persist structured data you’ll reload later.

Exercises

  1. The wordlist misses POST-only routes like /api/orders (a GET returns 404). Extend get into a probe(method, path) that can send either, and re-run discovery so POST endpoints show up too.
  2. Print a one-line summary after discovery: how many paths returned 200 vs 401, using two counters you increment in the loop.
  3. Diff the documented paths against what you discovered. Which live endpoint is in neither the spec’s list nor a 200/401 you’d expect? (Hint: it dumps more than it should.)

Next — Part 3: /api/users/{id} answered 401 without a token — but what happens with one? We point our own token at other users’ records and find our first real vulnerability: broken object-level authorization.

← All parts