July 17, 2026 · v0.9.0

Grape API Scanner + auditing Mastodon and GitLab

How we built a Grape API parser, expanded Rails concerns, and scanned two of the largest Ruby codebases on earth.

The challenge

After shipping v0.8.2 with Rails support, I scanned two major codebases:

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:

  1. 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.
  2. Mount chain resolutionmount ::API::Groups in lib/api/api.rb → resolves to lib/api/groups.rb, loads the file, and applies the root class's prefix :api + version 'v4' to produce /api/v4/groups.
  3. Auth via before blocks — Grape uses before do authenticate! end (not before_action). We detect both multiline and inline lambda forms.
  4. Do/end balance tracking — Route handlers contain Ruby code with their own if ... end blocks. We track do/end balance 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

MetricResult
Total routes detected2,449
Grape API routes (/api/v4/)1,017
Routes with auth detected813
Mount declarations resolved80+

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:

  1. Capture phase — When concern :approvable do ... end is 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).
  2. Expansion phase — When resources :tags, concerns: :approvable is encountered, the concern's captured routes are replayed with the current resource's path prefix + :id param.

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

Metricv0.8.2v0.9.0
Total routes743766 (+23 from concern expansion)
unknown#unknown routes50

The full journey: v0.8.0 → v0.9.0

VersionMastodon CRITICAL (no auth)What changed
v0.8.0505Initial Rails scanner
v0.8.1264Controller inheritance chain walking
v0.8.2216with_options, controller: modifier, pluralization
v0.9.0Concern expansion eliminated unknown#unknown

57% false-positive reduction across 4 releases, driven entirely by real-world testing on Mastodon.


What I learned

  1. 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.
  2. 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.
  3. Do/end balance tracking is harder than it looks. Route handlers contain arbitrary Ruby code with if, case, begin, do blocks. A simple end counter isn't enough — you need to count do keywords too.
  4. 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.
  5. 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:

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