A single-command static API surface audit.
Express · Fastify · NestJS · OpenAPI 2/3 One command. One report. One exit code.
ApiGate is the fourth surface in the Stelnyx line (LuxScope, LuxFaber, SecGate, ApiGate). It reads source code and OpenAPI specs on disk and produces one report: every endpoint, its declared auth posture, the drift between code and spec, scored against five 0–100 rubrics. 100% static — no HTTP requests, no credentials, no running server. Zero network calls. Same input → byte-identical output.
Honest positioning. ApiGate is a surface auditor, not a runtime scanner and not a DAST tool. The report explicitly states what a static analysis CAN and CANNOT prove — it's a printed trust feature, not a footnote. ApiGate cannot verify runtime authorization (BOLA / object-level access). It can prove that an endpoint declares a guard. It cannot prove the guard is correct. See What this does NOT prove.
Status. Early release (v0.3.0). Published with npm provenance. Report vulnerabilities via SECURITY.md.
Accuracy contract. ApiGate's parsing + scoring pipeline is deterministic — same inputs produce JSON- and HTML-byte-identical reports across every run. Three test suites lock the contract:
test/determinism.mjs— byte-equality of JSON and HTML across reruns of every parser, classifier, diff, score, and renderer call.test/golden-apigate.mjs— hand-crafted multi-framework fixture; expected endpoint count, posture distribution, drift buckets, and headline score are inline so any change surfaces in code review.test/report-theme-contract.mjs— asserts the HTML report is rendered through@stelnyx/report-theme, not by a parallel local renderer.
Every run produces a Headline Score (0–100) alongside the binary PASS/FAIL gate. The score is the arithmetic mean of five 0–100 rubrics:
Headline: 86 / 100 █████████████████░░░ STRONG rubric v1
Inventory 86 / 100 █████████████████░░░
Auth Coverage 67 / 100 █████████████░░░░░░░
Open Risk 85 / 100 █████████████████░░░
Spec Drift 90 / 100 ██████████████████░░
Determinism 100 / 100 ████████████████████
| Rubric | What it measures |
|---|---|
| Inventory Resolved | 100 × resolved / total. Routes mounted via dynamic prefixes drag this down — never silently dropped. |
| Auth Coverage | 100 × guarded / (guarded + open + unknown). UNKNOWN counts against — a route we couldn't classify is not a confirmed guard. Intentional-public endpoints (login/signup/refresh/health/...) are excluded — they're declared public-by-design, not a coverage gap. |
| Open Endpoint Risk | Starts at 100. Open write methods (POST/PUT/PATCH/DELETE) -12 each; open GETs -3 each. Floor 0. |
| Spec Drift | Starts at 100. Shadow -5, stale -5, auth-drift -10 each. null (excluded from mean) when no spec found. |
| Determinism | Locked at 100 in v0.1. The byte-equality test is what actually enforces it. |
Bands: STRONG ≥ 85, GOOD ≥ 70, MIXED ≥ 50, WEAK < 50. Same bands as SecGate / LuxFaber / LuxScope.
The binary gate (exit 1 on open write methods, configurable) is independent of the score. headlineScore + rubrics are written to apigate-report.json for CI dashboards and trend tracking.
npx @stelnyx/apigate .Inventories every HTTP endpoint, classifies auth posture, diffs against any OpenAPI spec, writes a JSON report + a self-contained HTML report. Exit 0 on clean, 1 on open-write endpoints (default policy). That's the whole product.
ApiGate walks source files in a directory and produces:
- One normalized JSON report — every endpoint, posture, drift bucket, rubric, headline
- One self-contained HTML report — rendered through
@stelnyx/report-theme; visually + structurally identical to SecGate / LuxScope / LuxFaber reports - One exit code —
1on open-write endpoints (configurable); blocks CI
ApiGate does not run code. It does not send HTTP requests. It does not require credentials or a running server. Everything happens at parse-time against source files.
| Framework | Detection |
|---|---|
| Express | acorn AST for .js/.mjs/.cjs; regex fallback for .ts/.tsx. Resolves app.use('/prefix', router) mounts in same file. |
| Fastify | acorn AST. Handles fastify.<method>(...) and fastify.route({ method, url, preHandler }). |
| NestJS | Decorator extraction over .ts/.js. Pairs @Controller(...) with method decorators @Get / @Post / @Put / @Patch / @Delete / @Options / @Head / @All. Captures class- and method-level guards (@UseGuards, @Auth, @Roles, @ApiBearerAuth). |
| OpenAPI | Parses *.yaml/*.yml/*.json with swagger: (2.0) or openapi: (3.x) root. Honors operation-level security and root inheritance. |
Routes that can't be statically resolved (dynamic mount prefixes, computed method names, unparseable files) are emitted as UNRESOLVED — never guessed. They count against the Inventory Resolved rubric so the report tells you what ApiGate could and couldn't see.
ApiGate makes zero network calls. No telemetry, no account, no phone-home, no advisory database, no spec fetching from URLs. Reports are written to local files only. Code never leaves your machine.
This is unconditional, not a flag. Air-gapped CI works as well as a developer laptop.
The honesty wedge of ApiGate. The report renders this section in every run, never hidden behind a toggle:
- Static analysis cannot verify runtime authorization (BOLA / object-level access). ApiGate only inspects declared auth posture, not the correctness of the auth middleware itself.
- An endpoint with a declared auth identifier in its middleware chain is classified
GUARDED— the middleware may still be misconfigured, bypassable, or insecure at runtime. - Routes mounted via dynamic strings (computed prefixes, runtime registration, factory functions) are reported as
UNRESOLVEDrather than guessed. - ApiGate uses a built-in heuristic pattern list to mark common public-auth paths (login / signup / refresh / health / OAuth callbacks) as intentional-public. The heuristic is deterministic and configurable but can be wrong both ways — review the marked endpoints and override
publicAuthPatternsin.apigate.config.jsonif your project disagrees. - ApiGate makes zero network calls. It does not send code, telemetry, or scan artifacts anywhere.
If you need runtime authorization testing (BOLA, IDOR, broken object-level auth), use a dynamic scanner. ApiGate complements DAST tooling; it does not replace it.
Most APIs intentionally expose a handful of unauthenticated endpoints — sign-up, login, refresh, password reset, OAuth callbacks, health probes. Without help, ApiGate's openWriteMethods: true default would flag every one of these as FAIL on first run.
ApiGate ships a deterministic, exact-match list of these patterns (see lib/heuristics.mjs → DEFAULT_PUBLIC_AUTH_PATTERNS). Endpoints matched by the list:
- Stay in the inventory — they're never hidden.
- Keep their declared posture (OPEN / UNKNOWN) — the heuristic does not lie about what the code says.
- Are excluded from the default exit-1 gate.
- Are excluded from the Auth Coverage denominator.
- Are tagged
PUBLIC(in addition to their posture) in the HTML report.
Override the pattern list:
{
"publicAuthPatterns": [
{ "method": "POST", "path": "/api/v2/auth/login" },
{ "method": "GET", "path": "/api/v2/auth/google/callback" }
]
}[] disables the heuristic entirely (every open endpoint counts).
failOn.intentionalPublic: true keeps the heuristic for the report but makes those endpoints fail the gate (strict / compliance mode).
npm install -g @stelnyx/apigatenpx @stelnyx/apigate .git clone https://github.com/Stelnyx/ApiGate.git
cd ApiGate
npm install
chmod +x apigate.js
sudo ln -sf "$(pwd)/apigate.js" /usr/local/bin/apigate# Scan current directory
apigate .
# Scan a specific path
apigate /path/to/project
# Write reports to a custom directory
apigate /path/to/project --output-dir /tmp/reports
# Strip absolute paths (auto-on when CI=true)
apigate /path/to/project --strip-paths
# JSON only / HTML only
apigate . --format json
apigate . --format html
# Show parser warnings
apigate . --debug
# PR-aware: did this branch introduce a new unauthenticated route?
apigate . --diff main
# Narrow the visible endpoint table (view-only — does not affect exit code)
apigate . --filter risk=HIGH,posture=OPEN
# Print one endpoint's evidence chain (no files written, exit 0)
apigate . --explain GET /audit/security-events
# Tighten the gate at CI time without editing .apigate.config.json
apigate . --fail-on open-write,unknown,drift,missing-spec,new-open-write
# Byte-stable timestamp for CI (matches SOURCE_DATE_EPOCH convention)
APIGATE_TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" apigate .
# Version / help
apigate --version
apigate --help--fail-on tokens (comma-separated):
| Token | Maps to |
|---|---|
open-write |
failOn.openWriteMethods = true |
open-read |
failOn.openReadMethods = true |
unknown |
failOn.unknown = true |
drift |
failOn.drift = true |
intentional-public |
failOn.intentionalPublic = true |
new-open-write |
failOn.newOpenWrite = true (needs --diff) |
missing-spec |
requireSpec = true |
Unknown tokens throw — CI typos can never silently relax policy.
--filter grammar:
key=value
key=v1|v2 # OR within a key
key1=v1,key2=v2 # AND across keys
Keys: risk (HIGH|MED|LOW), posture (GUARDED|OPEN|UNKNOWN), framework
(express|fastify|nest|openapi), method (any HTTP verb), changed
(added|removed|changedPosture|changedRisk — requires --diff).
Exit codes
| Code | Meaning |
|---|---|
0 |
PASS — gate.reasons is empty |
1 |
FAIL — gate.reasons[] lists the exact gate(s) that fired |
2 |
Invalid target or CLI error |
The gate.reasons[] array in the JSON report holds the locked enum set
(drift, intentional-public, missing-spec, open-read, open-write,
unknown-endpoint) sorted lexicographically and deduplicated, so CI can
grep failure causes without scraping stdout.
Create .apigate.config.json in your scan target directory. All fields optional.
{
"frameworks": {
"express": true, "fastify": true, "nest": true, "openapi": true
},
"auth": {
"express": ["requireAuth", "passport.authenticate", "jwt"],
"fastify": ["onRequest", "preHandler", "fastify.authenticate"],
"nest": ["UseGuards", "AuthGuard", "JwtAuthGuard"]
},
"failOn": {
"openWriteMethods": true,
"openReadMethods": false,
"unknown": false,
"drift": false,
"intentionalPublic": false
},
"requireSpec": false,
"strictPublic": false,
"excludePaths": ["node_modules/**", "dist/**", "**/*.test.*"]
}A fully-commented example config lives at .apigate.config.example.json — copy into your repo as .apigate.config.json and edit.
| Field | Type | Default | Description |
|---|---|---|---|
frameworks |
object | all true |
Toggle individual parsers |
auth |
object | per-framework defaults | Identifier names that classify an endpoint as GUARDED when present |
failOn |
object | { openWriteMethods: true, unknown: false, drift: false } |
Exit-code policy |
requireSpec |
boolean | false |
Fail when no OpenAPI 2/3 spec is detected |
strictPublic |
boolean | false |
Disable built-in public-auth patterns unless publicAuthPatterns is set |
excludePaths |
array | node_modules/, dist/, test dirs |
Globs of files to skip |
CLI flag > .apigate.config.json > built-in defaults
Missing config: silent, defaults apply. Invalid JSON: error logged, defaults apply.
ApiGate's classifier reports GUARDED when one of the declared auth identifiers appears in the endpoint's middleware/decorator chain. The defaults cover the common framework idioms (passport.authenticate, @UseGuards, Fastify preHandler). For project-specific helpers (e.g., a custom requireOrg middleware), extend the list:
{ "auth": { "express": ["requireAuth", "requireOrg", "requireBilling"] } }ApiGate does not inspect what these identifiers do at runtime. Their declared presence is the signal — that's the static-analysis honesty contract.
ApiGate is scanned against four targets each release. The first is the deterministic test fixture (locked at 100/100 — that's how byte-equality is proven). The rest are real public / production codebases — their numbers move with each scan because the codebases move.
| Target | Framework | Endpoints | Headline | Role |
|---|---|---|---|---|
samples/realworld-express (vendored, SHA-pinned) |
Express | 20 | 100 | Determinism baseline — locked at 100/100, used by test/determinism.mjs for byte-equality assertions. Not a marketing showcase. |
samples/immich-nest (cloned, SHA in SOURCE) |
NestJS | 255 | 64 | Real public NestJS reference — Immich, ~50K★. 183 guarded · 35 open · 14 HIGH risk. |
samples/ghost-express (cloned, SHA in SOURCE) |
Express | 303 | 66 | Real public Express reference — Ghost, ~50K★. Demonstrates auth.express customization. |
| slstudio (private) | NestJS | 423 | 70 | Private production reference. 330 guarded · 96 open · 52 HIGH risk. |
What the numbers say.
nest-realworld100/100 doesn't mean ApiGate "passed Nest" — it means we lock byte-equality against that fixture.immich64/100 andghost66/100 are honest scans of real codebases against ApiGate's default policy + lightweight per-repo config. Both have real OPEN findings worth triage; both also exhibit the known parser limits documented inparserCapabilities(global-guard pattern, project-specific middleware names).slstudio70/100 internal — surfaced ~39 false-GUARDED endpoints whose only declared marker was@ApiBearerAuth(Swagger doc decorator) on first run. Reviewable in 30 seconds viamatchedAuthMarker.
Reproducing the public scans:
# Immich
git clone --depth 1 https://github.com/immich-app/immich /tmp/immich
APIGATE_TIMESTAMP="2026-05-20T00:00:00.000Z" \
apigate /tmp/immich/server --strip-paths
# Ghost (needs a small auth.express extension — see samples/ghost-express/SOURCE.md)
git clone --depth 1 https://github.com/TryGhost/Ghost /tmp/ghost
APIGATE_TIMESTAMP="2026-05-20T00:00:00.000Z" \
apigate /tmp/ghost/ghost/core --strip-pathsFor tighter CI gates:
{
"failOn": { "unknown": true, "intentionalPublic": true, "newOpenWrite": true },
"requireSpec": true,
"strictPublic": true
}failOn.unknown means "cannot prove guarded" fails the run. requireSpec prevents a green drift score when no OpenAPI file is present. strictPublic disables the built-in public-auth list unless your repo provides publicAuthPatterns. newOpenWrite (default ON with --diff) fails when a PR adds an unauthenticated write endpoint.
- name: Run ApiGate
run: npx @stelnyx/apigate .
# exits 1 on open write endpoints by default — blocks the pipeline- name: Run ApiGate
run: npx @stelnyx/apigate . || true
- name: Upload report
uses: actions/upload-artifact@v4
with:
name: apigate-report
path: |
apigate-report.json
*.htmlEach run writes:
apigate-report.json— machine-readable, schema below<repo-name>.html— self-contained HTML report (rendered through@stelnyx/report-theme)
{
"version": "0.3.0",
"rubricVersion": "v1",
"riskVersion": "v1",
"timestamp": "ISO 8601",
"target": "/absolute/path or repo basename",
"mode": "static",
"status": "PASS | FAIL",
"gate": {
"status": "PASS | FAIL",
"reasons": ["drift", "intentional-public", "missing-spec", "new-open-write", "open-read", "open-write", "unknown-endpoint"]
},
"headlineScore": 86,
"rubrics": {
"inventoryResolved": 86,
"authCoverage": 67,
"openEndpointRisk": 85,
"specDrift": 90,
"determinism": 100
},
"summary": {
"endpoints": 7, "resolved": 6, "unresolved": 1,
"guarded": 4, "open": 2, "unknown": 1, "intentionalPublic": 0,
"risk": { "HIGH": 1, "MED": 2, "LOW": 4 },
"specEndpoints": 6, "shadow": 1, "stale": 1, "authDrift": 0,
"unknownReasons": { "mount-prefix-not-static": 1 }
},
"endpoints": [
{
"method": "GET | POST | PUT | PATCH | DELETE | OPTIONS | HEAD | ALL",
"path": "/users/:id",
"file": "src/server.js",
"line": 42,
"framework": "express | fastify | nest | openapi",
"resolved": true,
"posture": "GUARDED | OPEN | UNKNOWN",
"intentionalPublic": false,
"authMarkers": ["requireAuth"],
"matchedAuthMarker": "requireAuth",
"risk": "HIGH | MED | LOW",
"riskReason": "open write method",
"unresolvedReason": null
}
],
"drift": {
"shadow": [{ "kind": "shadow", "method": "POST", "path": "/x", "note": "..." }],
"stale": [{ "kind": "stale", "method": "GET", "path": "/y", "note": "..." }],
"authDrift": [{ "kind": "authDriftDeclaredOnly", "method": "PUT", "path": "/z", "note": "..." }]
},
"refDiff": {
"baseRef": "main",
"baseSha": "abc12345...",
"added": [{ "method": "POST", "path": "/admin/purge", "posture": "OPEN", "risk": "HIGH" }],
"removed": [],
"changedPosture": [{ "method": "PUT", "path": "/x", "from": "GUARDED", "to": "OPEN" }],
"changedRisk": [{ "method": "POST", "path": "/y", "from": "MED", "to": "HIGH" }]
},
"frameworksDetected": ["express", "openapi"],
"specsDetected": ["openapi.yaml"],
"parserCapabilities": {
"express": { "sameFileMountPrefix": true, "crossFileMountPrefix": false, "dynamicMountPrefix": "UNRESOLVED" },
"fastify": { "registerPrefixPropagation": false, "routeOptionsParsed": true },
"nest": { "controllerObjectArg": "path field only", "classLevelGuards": true },
"openapi": { "missingSecurity": "UNKNOWN (conservative)", "operationSecurityEmptyArray": "OPEN" }
},
"riskTier": { "version": "v1", "sensitivePathTokens": 9, "buckets": ["HIGH","MED","LOW"], "overrideHook": "config.severityOverrides" },
"filter": "risk=HIGH,posture=OPEN",
"warnings": [],
"limitations": ["..."]
}| Added in | Key | Why |
|---|---|---|
| 0.1.2 | gate.reasons[] |
Enum-locked list of gates that fired. CI grep target. |
| 0.1.2 | endpoints[].matchedAuthMarker |
First declared identifier that triggered a GUARDED classification. |
| 0.1.2 | summary.unknownReasons{} |
Per-unresolvedReason counts. |
| 0.1.2 | parserCapabilities{} |
Frozen matrix of what each parser sees vs. leaves unresolved. |
| 0.2.0 | refDiff{} |
PR-aware diff vs a git ref. Surfaces added / removed / changedPosture / changedRisk. |
| 0.2.0 | endpoints[].risk / riskReason |
Per-endpoint HIGH/MED/LOW tier. Frozen v1 ladder. |
| 0.2.0 | summary.risk{} |
HIGH/MED/LOW counts. |
| 0.2.0 | riskTier{} + riskVersion |
Rubric version of the risk ladder. |
| 0.2.0 | filter |
The filter expression in effect (view-only). |
| 0.3.0 | parserCapabilities.nest.methodDecoratorsBelowAnchor |
Now true — bidirectional decorator scan captures markers above AND below @Get/@Post. |
| 0.3.0 | parserCapabilities.nest.globalGuardDetection |
Documented as NOT DETECTED — NestJS APP_GUARD / useGlobalGuards providers are invisible. Use auth.nest overrides or severityOverrides. |
| 0.3.0 | LIMITATIONS entry #7 | Global-guard blind spot disclosed in every printed report. |
| Posture | Meaning |
|---|---|
| GUARDED | At least one declared auth identifier present in the endpoint's middleware/decorator chain |
| OPEN | Endpoint resolved, no auth identifier present |
| UNKNOWN | Endpoint itself is unresolved, OR (specs) no security block present |
matchedAuthMarker (added in v0.1.2) is the lever for catching a false GUARDED without changing the trust model. The classifier still flags an endpoint GUARDED when any declared identifier appears in its decorator chain — but the report now shows you exactly which identifier triggered the match, so an unusual one stands out.
Real example from dogfooding v0.1.2 against a 423-endpoint production NestJS app:
matchedAuthMarker distribution (GUARDED endpoints):
313 ApiBearerAuth ← Swagger documentation decorator
12 UseGuards ← actual runtime guard
2 Roles
@ApiBearerAuth() is a Swagger documentation decorator, not a runtime guard. 313 of the 327 GUARDED endpoints matched on it. Most are still protected by a global APP_GUARD registered in app.module.ts (invisible to static analysis — see parserCapabilities.nest), but 39 had no other guard identifier in their decorator chain at all. Without matchedAuthMarker those would have been silent in the report. With it, the reviewer has a 30-second triage path.
If your codebase wants to drop the false signal entirely, remove ApiBearerAuth from auth.nest in .apigate.config.json. ApiGate's defaults are intentionally permissive (the cost of missing a real guard outweighs the cost of a false GUARDED that matchedAuthMarker makes visible).
ApiGate is visually + structurally indistinguishable from SecGate, LuxScope, and LuxFaber. The HTML report is rendered through @stelnyx/report-theme@^0.1.0 — the same version, same imports, same shell envelope, no fork, no local restyle. See DESIGN_PARITY.md for the contract.
If a missing theme component blocks ApiGate, the resolution is an upstream PR to report-theme, not a local patch. That's how the four-tool shelf stays one product.
| Doc | What's in it |
|---|---|
CHANGELOG.md |
Version history — Added / Changed / Fixed per release |
OPEN-CORE.md |
OSS core boundary and paid extension roadmap |
DESIGN_PARITY.md |
The report-theme consumption contract |
SECURITY.md |
Vulnerability reporting, SLA, coordinated disclosure |
CONTRIBUTING.md |
Dev setup, branch + commit conventions, PR checklist |
See CONTRIBUTING.md. Report vulnerabilities privately per SECURITY.md — do not open public issues for security reports.
MIT — © Stelnyx