// 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.
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/debugis 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/statsis 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_mapmaps and flattens — ideal for turning a hash ofpath => [methods]into a flat list of[method, path]pairs.%w[a b c]builds a string array cheaply;eachiterates;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_generatepersist structured data you’ll reload later.
Exercises
- The wordlist misses POST-only routes like
/api/orders(a GET returns 404). Extendgetinto aprobe(method, path)that can send either, and re-run discovery so POST endpoints show up too. - Print a one-line summary after discovery: how many paths returned 200 vs 401, using two counters you increment in the loop.
- 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.