Command Palette

Search for a command to run...

UnylyUnyly
Весь каталог

Express Recon

БесплатноНе проверен

Enables scanning Express.js route surfaces for inventory and audit, classifying routes as public/authenticated.

GitHubEmbed

Описание

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 --configaudit --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 io hints.
  • Request/response placeholders — a requestBody object 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.
  • Securitycomponents.securitySchemes + per-operation security from the audit's auth classification (run over audit, not inventory, to get this).
  • Traceback — every operation carries an x-express-recon extension with the handler source file:line, authStatus, middleware chain, handlerResolved, and handlerName (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 ./src and 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_authaudit_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) matches requireAuth). 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 run suggest-auth to 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/new chains, await client.connect() resolves, nothing ever rejects. Interception happens before module resolution, so the package doesn't even have to be installed. Routes registered inside connect().then(...) (or after an awaited connect) are still captured. Relative/absolute/node: imports — your actual app code — are never touched.
  • listen() never binds (the callback still fires) and process.exit is 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 partial diagnostic 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 const strings, + 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 — a use() 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 require and ESM import (default, named, namespace).
  • Module resolution via relative paths, package.json imports subpath aliases (#routes/*, including conditions objects), tsconfig paths aliases + baseUrl, and barrel re-exports (export { default } from …, export * from …).
  • express.Router() whether imported by require, default, or named Router.
  • 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-tests to 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 hybrid to 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 tsconfig that isn't found, emitted with an unknown prefix. tsconfig extends chains aren't followed.
  • Regex or computed use() scopes: the guard is kept on the whole host (errs toward "has middleware", never toward "public").

from github.com/chiz0me/express-recon

Установка Express Recon

У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.

▸ github.com/chiz0me/express-recon

FAQ

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

Compare Express Recon with

Не уверен что выбрать?

Найди свой стек за 60 секунд

Автор?

Embed-бейдж для README

Похожее

Все в категории development