Unchained Labs

authsweep

Half your auth audit should never have been a model call.

Enumerating routes and asking whether each one references the auth middleware is grep with a parser attached — deterministic, instant, free. On a real codebase it removes more than half the work before any agent runs.

  • alpha
  • 81 tests passing
  • zero tokens
  • JS/TS · Python · Rust
  • SARIF
  • MIT
Real output. 18 of 28 routes prefiltered before any agent ran.

The scan

Deterministic, and it shows its work

$ authsweep scan .

  authsweep  5 files · 28 routes · express, fastapi, flask

  prefilter  18 of 28 routes dropped before any agent ran
             64% of the fan-out, for zero tokens

   DELETE /admin/users/:id/roles  src/routes/admin.js:18
     app.delete('/admin/users/:id/roles', (req, res) => dropRoles(req.params.id))
     no authorization check found on or above this handler · DELETE changes state ·
     administrative surface · operates on user records · takes an id or wildcard

   POST /billing/charge  src/routes/billing.js:19
     app.post('/billing/charge', (req, res) => charge(req.body))
     no authorization check found on or above this handler · POST changes state · touches money

  7 high ·  · 1 low

The prefilter

The free half, done properly

The reference architecture calls this out as the step people skip: you pay full price to have a model scan files that were obviously fine. authsweep does the deterministic half first and hands you a much shorter list.

  • References a guard on the handler, its middleware chain, or at router levelapp.use(requireAuth) covers everything below it, and a FastAPI APIRouter(dependencies=[...]) covers its whole router.
  • Conventionally public — /health, /metrics, /auth/login, /.well-known/*.
  • Marked public in a comment or decorator — // public, @public, no-auth.
  • A stub that returns 501 or raises NotImplementedError.

Every prefilter decision is recorded and auditable. --format json lists the dropped routes with the reason each one was dropped. A prefilter you cannot inspect is a prefilter you cannot trust — and one that is too aggressive is worse than no prefilter at all, which is why the fixtures test each drop reason explicitly.

A path is not a guard

An early version read app.post('/auth/login', handler) as guarded, because the word auth in the path matched the guard pattern. Every /auth/* route in a codebase would have been silently treated as protected. Path literals are now stripped before guard detection, and there is a test for it.

Supported

Three languages, eight routers

Language Frameworks, and what is resolved
JS / TS Express, Fastify (call and object form), Koa. Parsed with a real JS parser, because app.get('/x', mw, handler) needs argument positions. TypeScript annotations are stripped length-preservingly, so line numbers stay correct.
Python FastAPI, including Depends and router-level dependencies, and Flask, including methods=.
Rust axum — .route, chained method routers, .nest, .layer/.route_layer, extractor-type guards. actix-web — #[get] attribute macros, web::scope prefixes, .wrap guards. rocket — attribute macros, .mount + routes![] prefixes, request guards.

Rust without a Rust parser

There is no Rust parser here, so extraction is token-structural: a brace-aware scanner reads the routing call, and the handler is resolved by name in the same file. That covers what rustfmt actually emits — a route wrapped across four lines, post(create).get(list) counted as two exposures rather than one, axum::routing::patch, and a raw-string path whose braces would derail a scanner that does not know about string literals.

It was written against a real service rather than invented fixtures, and that mattered twice. web::scope("/admin").wrap(auth) chains after its own arguments, so attributing the guard to the enclosing function marks every sibling route covered — a false clean. And resolving .mount("/api/v1", …) turned /ping into /api/v1/ping, which the head-anchored public-path list stopped recognising: the tool started reporting health checks precisely because it had got better at reading the router.

Two things it does not resolve, and both fail toward reporting. A router composed across files keeps its own path but loses the prefix applied elsewhere, and a MethodRouter assigned to a variable first resolves no handler. In each case the route is reported rather than assumed covered — for this tool that is the only acceptable direction to be wrong in.

Severity

Not every missing check is the same event

A missing check on DELETE /admin/users/:id/tokens and a missing check on GET /search are different problems. A scanner that reports them at the same level teaches you to ignore both.

Factor Weight
Mutating verb — POST / PUT / PATCH / DELETE +2
admin, internal, private, debug +3
Money — billing, charge, refund, payout +3
Credentials — token, secret, key, password +3
Authorization state — role, permission, scope, grant +3
User records — user, account, tenant, member +2
Bulk egress — export, download, dump, backup +2
Takes an id or wildcard +1

high at 4+, medium at 2–3, low below. Every clause that contributed appears on the finding, so you can disagree with a score and see exactly why it scored.

Evidence or nothing. Every finding carries the verbatim source line it came from, and the SARIF output carries the snippet, so the GitHub Security tab shows the code rather than a description of it. A finding without a quotable span is a guess.

Verify

The paid stage is opt-in

The scan is free. Verification costs money — so authsweep hands you the graph instead of quietly spawning 3 × findings agents on your behalf.

$ authsweep verify-spec . > verify.graph.json
$ graphlint check verify.graph.json
 1 file clean
$ preflight estimate verify.graph.json
  agents  25   cost  $0.359   budget $1.00 ✓ under

The emitted spec is tiered, schema-bound, budgeted, uses three distinct lenses, and carries a barrierReason on its one barrier. It passes graphlint with no findings — asserted in CI, because a tool that emits a spec its own org's linter rejects is not a family, it is three unrelated repos. Low-severity findings are excluded from the paid stage.

Limits

What it does not do

It is not a taint analyser. It answers is there an auth check here, not is the check correct. A route with requireAuth that then reads req.params.id with no ownership check is invisible to it — that is what the verify lenses are for.

It will miss unusual architectures. Auth enforced in a gateway, a service mesh, a decorator factory or generated code reads as missing. Mark those routes public.

A file it cannot parse is reported, never assumed clean. Unparseable files are listed in the output and the JSON summary — a file we cannot read is not a file without findings.

It reads three languages' worth of routers, and no more. Go, Ruby, Java, PHP and C# are invisible to it. A scan that found no routes says so loudly and sets summary.examinedNothing, because a scan that examined nothing must never read as a scan that found nothing.

It does not scan other people's repositories for you. If you want to do that, the responsible-disclosure decisions are yours to make.