Grape API Scanner + auditing Mastodon and GitLab
The challenge
After shipping v0.8.2 with Rails support, I scanned two major codebases:
- Mastodon (50k stars, 743 routes) — 5 routes remained as
unknown#unknownbecause of Rails concerns - GitLab (71k files, 1,144 API endpoints) — 0 API routes detected because GitLab uses Grape, not Rails routes
Two features were needed: a Grape API scanner and Rails concern expansion.
Feature 1: Grape API Scanner
What is Grape?
Grape is a REST-like API micro-framework for Ruby. It's used by GitLab, Discourse, and hundreds of production APIs. Routes are defined in lib/api/*.rb files using a completely different DSL from Rails:
module API
class Groups < ::API::Base
resource :groups do
get ':id' do
# GET /groups/:id
end
end
end
end
How we built it
The Grape scanner mirrors the Rails scanner's architecture — a block-aware parser with a context stack that tracks path prefixes and auth state.
Key design decisions:
- Auto-detection, no new flag — When
lib/api/directory exists, the Grape scanner runs automatically as a second pass after the Rails scanner. Routes are merged. - Mount chain resolution —
mount ::API::Groupsinlib/api/api.rb→ resolves tolib/api/groups.rb, loads the file, and applies the root class'sprefix :api+version 'v4'to produce/api/v4/groups. - Auth via
beforeblocks — Grape usesbefore do authenticate! end(notbefore_action). We detect both multiline and inline lambda forms. - Do/end balance tracking — Route handlers contain Ruby code with their own
if ... endblocks. We trackdo/endbalance to skip handler bodies without corrupting the resource context stack.
Bugs caught during development
Bug: Route handler do...end consuming resource context. When get do ... end appeared inside resource :groups do, the handler's end would pop the resource context instead of the handler. The fix: set a skip depth after each route declaration, with do/end balance tracking for nested blocks inside handlers.
Results on GitLab
| Metric | Result |
|---|---|
| Total routes detected | 2,449 |
Grape API routes (/api/v4/) | 1,017 |
| Routes with auth detected | 813 |
| Mount declarations resolved | 80+ |
Feature 2: Rails Concern Expansion
The problem
Mastodon uses Rails concerns to share route blocks across resources:
concern :approvable do
member do
post :approve
post :reject
end
end
with_options only: [:index], concerns: :approvable do
resources :tags
resources :links
resources :statuses
end
Before v0.9.0, the scanner produced unknown#unknown for these routes because the concern block wasn't expanded.
The fix
Two-phase parsing:
- Capture phase — When
concern :approvable do ... endis encountered, routes are captured into a separate array (not emitted to the main routes list). A fresh context with empty path prefix is used so the concern's routes have relative paths (e.g.,/approve, not/api/v1/admin/trends/approve). - Expansion phase — When
resources :tags, concerns: :approvableis encountered, the concern's captured routes are replayed with the current resource's path prefix +:idparam.
Bugs caught during development
Bug: Concern paths using absolute prefix. When a concern was defined inside namespace :api do namespace :v1 do namespace :admin do namespace :trends do, the captured routes had paths like /api/v1/admin/trends/approve. When expanded inside resources :tags, this produced /api/v1/admin/trends/tags/{id}/api/v1/admin/trends/approve — the prefix was duplicated. The fix: use a fresh context (empty pathPrefix) for concern definitions.
Results on Mastodon
| Metric | v0.8.2 | v0.9.0 |
|---|---|---|
| Total routes | 743 | 766 (+23 from concern expansion) |
unknown#unknown routes | 5 | 0 |
The full journey: v0.8.0 → v0.9.0
| Version | Mastodon CRITICAL (no auth) | What changed |
|---|---|---|
| v0.8.0 | 505 | Initial Rails scanner |
| v0.8.1 | 264 | Controller inheritance chain walking |
| v0.8.2 | 216 | with_options, controller: modifier, pluralization |
| v0.9.0 | — | Concern expansion eliminated unknown#unknown |
57% false-positive reduction across 4 releases, driven entirely by real-world testing on Mastodon.
What I learned
- Real codebases are messier than documentation. GitLab's
resource source_type.pluralize(dynamic resource name) is unparseable by static analysis. We skip it with a warning instead of crashing. - Two-pass parsing is essential for concerns. Forward references (concern used before defined) require capturing all definitions first, then expanding. Single-pass parsers can't handle this.
- Do/end balance tracking is harder than it looks. Route handlers contain arbitrary Ruby code with
if,case,begin,doblocks. A simpleendcounter isn't enough — you need to countdokeywords too. - Fresh contexts prevent path duplication. When a block is a template (concern), it should NOT inherit the surrounding namespace's path prefix. The template's paths are relative; the expansion site provides the prefix.
- Brutal testing prevents embarrassing bugs. The 21-test brutal stress test (empty files, malformed Ruby, circular inheritance, unicode paths, 100 resources, 50-segment paths) caught issues that toy fixtures would never surface.
Try it
npm install -g shadowaudit@0.9.0
# Scan Mastodon
git clone https://github.com/mastodon/mastodon.git
shadowaudit --dir ./mastodon --framework rails --generate-spec > mastodon-spec.json
shadowaudit --dir ./mastodon --framework rails --spec mastodon-spec.json
# Scan GitLab
git clone https://github.com/gitlabhq/gitlabhq.git
shadowaudit --dir ./gitlabhq --framework rails --spec ./gitlabhq/doc/api/openapi/openapi_v2.yaml
What's next (v1.0.0)
v0.9.0 is the last minor before v1.0.0. The v1.0.0 roadmap includes:
- Reverse mode — find dead spec entries (paths in spec not in code)
- CI mode — PR-ready markdown output for PR comments
- Spec coverage scoring — how complete is your OpenAPI spec?
- Confidence scoring — high/medium/low auth detection confidence
- Monetization — Free (MIT), Pro ($19/mo), Team ($99/mo), Enterprise ($299/mo)
The tool is now production-ready for 7 frameworks across 3 languages (JavaScript, Python, Ruby). The next phase is about market reach, not framework coverage.
← back to blog