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
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 medium · 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 level —
app.use(requireAuth)covers everything below it, and a FastAPIAPIRouter(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
501or raisesNotImplementedError.
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.