Express Recon
БесплатноНе проверенEnables scanning Express.js route surfaces for inventory and audit, classifying routes as public/authenticated.
Описание
Enables scanning Express.js route surfaces for inventory and audit, classifying routes as public/authenticated.
README
An inventory & audit harness for Express 4/5 route surfaces, built to be driven by humans, CI, and AI agents off the same contract. It enumerates every route, method, middleware chain, and source location, then (in audit mode) classifies each route as proven (behind known auth), public (no recognised auth), or review (guarded by something opaque), and emits machine findings including per-verb auth gaps.
Two scanners, opposite failure modes:
- static (default): parses JS/TS source with an AST (resolves ESM imports, tsconfig path aliases, and barrel re-exports). No app boot, no setup in the target repo, source file/line for free. Misses dynamically-registered routes.
- runtime: loads the live app and walks its router stack. Sees dynamic routes; the app must import cleanly. Mount-path prefixes are captured via instrumentation, so they survive on Express 5.
- hybrid: static for breadth + locations, runtime to verify and recover what static missed. Lowest chance of missing an open endpoint.
CLI
express-recon <command> [options]
| command | what it does |
|---|---|
inventory |
list routes, methods, middleware chains, source (no judgment) |
audit |
inventory + classify (proven/public/review) + findings |
suggest-auth |
propose auth-middleware allowlist candidates (JSON) |
schema |
print the JSON Schema of the report contract |
inventory/audit also emit an OpenAPI 3.1 document via --format openapi
(see OpenAPI / Swagger output).
# Zero-setup audit of a checked-out repo:
express-recon audit --src ./ --config ./recon.config.js --format pretty
# CI / agent gate: non-zero exit if any unauthenticated route exists:
express-recon audit --src ./ --config ./recon.config.js --format json --fail-on public
# Bootstrap the allowlist on an unfamiliar repo:
express-recon suggest-auth --src ./ > candidates.json
# Verify static findings against the live app and catch dynamic routes:
express-recon audit --mode hybrid --src ./ --app ./src/app.js \
--config ./recon.config.js --format json,md --out ./recon-out
| option | meaning |
|---|---|
--mode static|runtime|hybrid |
scanner (default static) |
--src <dir> |
repo root to scan (static/hybrid; default cwd) |
--app <path> |
JS file exporting the Express app (runtime/hybrid) |
--config <path> |
JS file exporting { authMiddleware: { name: tag } } |
--format json,md,pretty,openapi |
output formats (default pretty) |
--out <dir> |
write routes.json/routes.md (else stdout) |
--fail-on <statuses> |
audit only: exit 2 if any route matches (e.g. public,unknown) |
--include-tests |
also scan test files/dirs (test/, __tests__/, *.test.*, *.spec.* are excluded by default) |
For agents & CI: the report contract
--format json emits one versioned, self-describing artifact. Run
express-recon schema for the full JSON Schema. Shape:
{
"schemaVersion": "1.1",
"tool": "express-recon",
"command": "audit", // or "inventory"
"mode": "static",
"routes": [
{
"method": "PATCH",
"path": "/widgets/:id",
"middlewares": [{ "name": "express.json", "kind": "call", "raw": "express.json()" }],
"source": { "file": "src/routes/widgets.js", "line": 12 },
"io": { // static/hybrid: request/response hints mined from the handler
"request": { "body": ["name"], "query": [], "params": ["id"], "headers": [] },
"responses": [{ "status": 200, "bodyKeys": ["id", "name"] }],
"statusCodes": [],
"handlerResolved": true,
"handlerName": "updateWidget",
"handlerSource": { "file": "src/routes/widgets.js", "line": 12 }
},
"pathConfidence": "full", // "partial" when a mount/path couldn't be resolved
"authStatus": "public", // audit only: proven | public | unknown
"tags": ["public"], // audit only
"accepted": true, // audit only: public but in the acceptedPublic baseline
"presence": "both" // hybrid only: both | static-only | runtime-only
}
],
"globalMiddleware": [{ "name": "helmet", "kind": "call", "raw": "helmet()" }],
"summary": { "routes": 1, "public": 1, "unknown": 0, "proven": 0, "accepted": 0 }, // audit only
"findings": [ // audit only
{ "id": "public-route", "severity": "high", "method": "PATCH",
"path": "/widgets/:id", "source": { "file": "...", "line": 12 },
"detail": "No recognised auth middleware guards this route." }
]
}
Finding ids: public-route, per-verb-gap (same path, different auth per
method), opaque-middleware, stale-baseline (an acceptedPublic entry that no
longer matches a public route). inventory reports omit summary/findings and
the per-route authStatus/tags.
An agent workflow: suggest-auth to draft the allowlist → write --config →
audit --format json → act on findings → --fail-on public to assert.
OpenAPI / Swagger output
--format openapi renders the route inventory as an OpenAPI 3.1 document:
express-recon audit --src ./ --config ./recon.config.js --format openapi --out ./out
# writes ./out/openapi.json (loads in Swagger UI / Redoc)
What the tool derives deterministically, with no app boot:
- Paths & operations — Express
:param/{param}/*templated to OpenAPI{param};router.all()expanded across the concrete verbs. - Parameters — path params from the template; query and header params from
the mined
iohints. - Request/response placeholders — a
requestBodyobject schema of the field names the handler reads (req.body.*, destructuring), and a response per status code (res.status(N).json({...})) carrying its top-level keys. - Security —
components.securitySchemes+ per-operationsecurityfrom the audit's auth classification (run overaudit, notinventory, to get this). - Traceback — every operation carries an
x-express-reconextension with the handlersourcefile:line,authStatus, middleware chain,handlerResolved, andhandlerName(the dotted callee, e.g.controllers.user.getUser) so an AI pass can jump straight to the controller method even on dependency-injected apps where the body isn't statically resolvable.
The schema bodies are placeholders — field names without refined types,
marked x-express-recon-unrefined. To turn them into real request/response JSON
Schema and per-endpoint notes, run the bundled openapi-doc skill, which
reads each handler's code (AI-assisted) and fills in the schemas, enums, shared
components/schemas, and summary/description for each operation, validates
the merged document, and renders a viewable HTML page (Redoc). The skeleton alone
is a usable, if under-specified, spec; the skill is what documents the
input/output structures.
Coverage depends on how the app wires handlers. Inline handlers and
first-party controllers resolve statically, so their request/response hints and
handlerSource come for free. Dependency-injection apps
(module.exports = (controllers) => { router.get('/x', controllers.foo.bar) })
and feature-flag/dynamic dispatch can't be followed statically — those operations
still get a correct skeleton (path, method, security, handlerName) but sparse
hints, and the openapi-doc skill documents them by reading the named controller.
Nothing is invented: an unresolved handler is flagged, not guessed.
MCP server (for agents)
A Model Context Protocol server exposes the harness as typed tools over stdio:
express-recon-mcp
Tools: inventory_routes({ dir }), audit_routes({ dir, authMiddleware? }),
suggest_auth({ dir }), openapi_spec({ dir, authMiddleware? }),
report_schema(). Each returns the JSON report contract (or, for
openapi_spec, an OpenAPI 3.1 document) as the CLI. Static mode only: the
MCP tools parse source and never
execute the target repo, so an agent can't be coerced into running untrusted
code. Runtime/hybrid stays a human-opt-in CLI path.
Register it with an MCP client (e.g. Claude Code / Claude Desktop):
{
"mcpServers": {
"express-recon": { "command": "npx", "args": ["express-recon-mcp"] }
}
}
Once registered, plain-language requests trigger the tools. Ask the agent things like:
"Which routes in this Express app have no authentication?"
"Audit this repo and list every publicly reachable endpoint."
"Inventory the routes in
./srcand show their middleware chains.""Suggest an auth-middleware allowlist for this codebase."
The agent picks the matching tool (audit_routes, inventory_routes, or
suggest_auth), runs it against the working directory, and acts on the returned
findings. A typical loop: suggest_auth → audit_routes with the chosen
allowlist → act on findings.
Library
const { inventory, audit, suggestAuth, buildReport, instrument, formatters } =
require("express-recon");
// primitives: opts is { mode, src?, app? }
const inv = inventory({ mode: "static", src: "./" }); // raw, no judgment
const reg = audit({ mode: "static", src: "./" }, config); // classified
const report = buildReport(reg, { command: "audit", mode: "static" });
console.log(formatters.markdown.format(report));
console.log(suggestAuth(inv).candidates);
// runtime: instrument the SAME express the app uses, BEFORE it registers routes,
// so mount-path prefixes survive (Express 5 compiles them away otherwise).
instrument(require("express"));
const live = audit({ mode: "runtime", app: require("./src/app") }, config);
The CLI does the instrument() step automatically for runtime/hybrid.
instrument() also captures use() path scopes (strings and arrays), so a
path-scoped guard is attributed only to routes under its prefix; without it,
Express 5 keeps no recoverable path and scoped middleware is conservatively
treated as host-wide.
The auth allowlist
authMiddleware maps a middleware name or dotted callee to a tag:
module.exports = {
authMiddleware: {
requireAuth: "authenticated",
"passport.authenticate": "session",
snsSignatureVerifier: "signed:aws-sns",
},
};
Classification (public-unless-proven):
- proven: the chain contains a middleware whose name/callee is allow-listed,
either directly or inside a wrapper call (
asyncHandler(requireAuth)matchesrequireAuth). Note: a wrapper that disables its argument would still match — allow-list only names that always enforce. - review (
unknown): no match, but the chain has an opaque middleware (an inline/anonymous closure, or an unnameable expression) that could be hiding auth. Surfaced, not assumed safe. - public: no match and every middleware is a nameable identifier or call you
could have allow-listed (
express.json, a logger). Treated as unauthenticated. If a named middleware here is auth, add it to the allowlist and re-run, or runsuggest-authto find candidates automatically.
The public baseline (acceptedPublic)
Some endpoints are meant to be open — health checks, webhooks, public reads. On a
brownfield repo they'd make --fail-on public unusable. acceptedPublic is a
reviewed allowlist of intentionally-open routes, keyed by METHOD /path:
module.exports = {
authMiddleware: { requireAuth: "authenticated" },
acceptedPublic: ["GET /health", "POST /webhooks/stripe"],
};
An accepted route stays public but is tagged accepted: its public-route
finding is suppressed and it no longer trips --fail-on public, so CI fails only
on new unauthenticated routes. The summary reports an accepted count.
The baseline is checked against reality: an acceptedPublic entry that no longer
matches a live public route — the route was deleted, or now has auth — surfaces as
a stale-baseline finding (severity low) so the list can be pruned and can't
silently pre-approve a future route that reuses the path.
Runtime / hybrid: sandboxed boot
--app is required for runtime/hybrid. The app is booted in a sandbox, so an
unmodified app loads even with no database, cache, or broker reachable:
- Infra clients are stubbed.
requires of common infra packages (pg,mysql2,ioredis,redis,mongoose,mongodb,kafkajs,amqplib,@prisma/client,knex,sequelize,typeorm,bullmq,nodemailer, any@aws-sdk/*, …) return inert stubs: every property/call/newchains,await client.connect()resolves, nothing ever rejects. Interception happens before module resolution, so the package doesn't even have to be installed. Routes registered insideconnect().then(...)(or after anawaited connect) are still captured. Relative/absolute/node:imports — your actual app code — are never touched. listen()never binds (the callback still fires) andprocess.exitis ignored during boot, so.catch(() => process.exit(1))teardown can't kill the scan.- Partial boots still report. If the app throws after registering routes
(say, a config validator the sandbox couldn't satisfy), the routes captured
up to that point are harvested and the report carries a
boot: … results may be partialdiagnostic instead of failing outright.
Everything the sandbox did is surfaced in report.diagnostics (and mirrored to
stderr as [warn] lines). Tune it via --config:
module.exports = {
boot: {
env: { DATABASE_URL: "postgres://x", SESSION_SECRET: "recon" }, // satisfy env validators
stubModules: ["my-internal-db-client", "@my-scope/"], // extras; trailing "/" = prefix
sandbox: false, // opt out entirely
},
};
boot.env matters as often as the stubs: many boots die in an envalid/zod
schema check, not on a socket.
Known limits: callback-style connects (client.connect(cb) — stub callbacks
are never invoked) and timer-deferred wiring (setTimeout(wireRoutes, …)) are
missed; ESM-only apps can't be required; an app that starts its own timers at
boot keeps the CLI alive. For those, the explicit gate still works — the CLI
sets EXPRESS_RECON_DRY=1 before requiring the app:
const DRY = process.env.EXPRESS_RECON_DRY === "1";
if (!DRY) { connectDB(); redis.ping(); }
const app = express();
// …route wiring…
if (!DRY) app.listen(PORT);
module.exports = app;
Static mode: what it resolves
Parses JavaScript and TypeScript (.js/.jsx/.cjs/.mjs/.ts/.tsx/.mts/.cts)
with oxc, no type-checking, no build step. It proves from the AST:
app.METHOD(path, …)and.route(path).all().get().post()chains —.all()links count as middleware for the sibling verbs registered after them.- Chained registrations (
app.use(a).use(b),router.get(…).post(…)). router.use([path], subRouter)mounts, including across files. Array paths (use(['/a','/b'], …),get(['/a','/b'], …)) expand to one route/mount per path.- Paths built from same-file
conststrings,+concatenation, and template literals (app.get(`${V1}/users`, …)). - Path-scoped middleware is scoped:
app.use("/admin", mw)guards only routes under/admin, and registration order is honored — ause()after a route does not guard it (matching real Express semantics). - Wrapped guards:
asyncHandler(requireAuth)matches the allowlist through the wrapper's arguments. - Cross-file links via
requireand ESMimport(default, named, namespace). - Module resolution via relative paths, package.json
importssubpath aliases (#routes/*, including conditions objects), tsconfigpathsaliases +baseUrl, and barrel re-exports (export { default } from …,export * from …). express.Router()whether imported byrequire, default, or namedRouter.x as T,x!, and parenthesized expressions are unwrapped.- Test files (
test/,tests/,__tests__/,*.test.*,*.spec.*) are excluded by default so fixture apps don't pollute the inventory (--include-teststo opt back in).
It does not resolve, and marks pathConfidence: "partial" rather than
silently dropping a route:
- Dynamically-registered routes (loops, data-driven), shown as
/<dynamic>. Use--mode hybridto recover them. - Registrar functions (
module.exports = (app) => { app.get(…) }): the routes are emitted with an unknown prefix plus a diagnostic naming the file, since the host is bound at the call site. Hybrid mode recovers the real paths and merges them back by suffix. - Non-literal mount paths/routers, and routers reached only through a
bare/node_modules import or a
tsconfigthat isn't found, emitted with an unknown prefix.tsconfigextendschains aren't followed. - Regex or computed
use()scopes: the guard is kept on the whole host (errs toward "has middleware", never toward "public").
Установка Express Recon
У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.
▸ github.com/chiz0me/express-reconFAQ
Express Recon MCP бесплатный?
Да, Express Recon MCP бесплатный — установка в пару кликов через Unyly без оплаты.
Нужен ли API-ключ для Express Recon?
Нет, Express Recon работает без API-ключей и переменных окружения.
Express Recon — hosted или self-hosted?
Self-hosted: сервер запускается локально на твоей машине командой из раздела установки.
Как установить Express Recon в Claude Desktop, Claude Code или Cursor?
Открой Express Recon на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.
Похожие MCP
GitHub
PRs, issues, code search, CI status
автор: GitHubFilesystem
Secure file operations with configurable access controls.
Memory
Knowledge graph-based persistent memory system.
Template MCP Server
A CLI tool to create a new Model Context Protocol server project with TypeScript support, dual transport options, and an extensible structure
автор: mcpdotdirectCompare Express Recon with
Не уверен что выбрать?
Найди свой стек за 60 секунд
Автор?
Embed-бейдж для README
Похожее
Все в категории development
