Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ One command. One report. One exit code.

---

**ApiGate** is the fourth surface in the Stelnyx line ([LuxScope](https://github.com/Stelnyx/LuxScope), [LuxFaber](https://github.com/Stelnyx/LuxFaber), [SecGate](https://github.com/Stelnyx/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.
**ApiGate** is part of the Stelnyx developer-tool family alongside [LuxScope](https://github.com/Stelnyx/LuxScope), [LuxFaber](https://github.com/Stelnyx/LuxFaber), and [SecGate](https://github.com/Stelnyx/SecGate). It reads source code and OpenAPI specs on disk and produces one report: every endpoint it can detect, its declared auth posture, code/spec drift, and five transparent 0–100 rubrics. **100% static** — no HTTP requests, credentials, or running server. Zero network calls. With a fixed report timestamp, the same input and configuration produce 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](#what-this-does-not-prove).

**Status.** Early release (`v0.3.3`). Releases publish from GitHub Actions with npm trusted publishing and provenance. Report vulnerabilities via [SECURITY.md](SECURITY.md).

**Accuracy contract.** ApiGate's parsing + scoring pipeline is deterministicsame inputs produce JSON- and HTML-byte-identical reports across every run. Three test suites lock the contract:
**Accuracy contract.** ApiGate's parsing + scoring pipeline is deterministic. With the same files, configuration, tool version, and report timestamp, it produces JSON- and HTML-byte-identical reports. 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.
Expand Down Expand Up @@ -84,6 +84,8 @@ ApiGate walks source files in a directory and produces:

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.

The bounded scan builds one sorted candidate-file index and shares it across all enabled parsers. File limits, default vendor/build exclusions, symlink skipping, depth limits, and progress reporting therefore apply once per scan rather than once per framework.

---

## Frameworks
Expand Down Expand Up @@ -540,7 +542,7 @@ If your codebase wants to drop the false signal entirely, remove `ApiBearerAuth`

## Design parity

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](DESIGN_PARITY.md) for the contract.
ApiGate shares the same report shell, navigation, typography, and design tokens as SecGate, LuxScope, and LuxFaber. The HTML report is rendered through `@stelnyx/report-theme@^0.1.4` with no fork or parallel document shell. See [DESIGN_PARITY.md](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.

Expand Down
17 changes: 12 additions & 5 deletions lib/inventory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { parseExpress } from "./parsers/express.mjs";
import { parseFastify } from "./parsers/fastify.mjs";
import { parseNest } from "./parsers/nest.mjs";
import { parseOpenApi } from "./parsers/openapi.mjs";
import { sortEndpoints } from "./utils.mjs";
import { sortEndpoints, walkFiles } from "./utils.mjs";

const CANDIDATE_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".tsx", ".yaml", ".yml", ".json"];

/**
* Orchestrate parsers, dedup by (framework, method, path, file, line),
Expand All @@ -22,26 +24,31 @@ export function buildInventory(targetDir, config) {
const frameworksDetected = new Set();
let specsDetected = [];

// One bounded, deterministic walk is shared by every enabled parser. Parser
// entrypoints still walk independently when called directly, preserving the
// public API used by consumers and focused parser tests.
const candidateFiles = walkFiles(targetDir, CANDIDATE_EXTENSIONS, excl, scan);

if (fw.express) {
const r = parseExpress(targetDir, excl, scan);
const r = parseExpress(targetDir, excl, scan, candidateFiles);
if (r.endpoints.length) frameworksDetected.add("express");
code.push(...r.endpoints);
warnings.push(...r.warnings);
}
if (fw.fastify) {
const r = parseFastify(targetDir, excl, scan);
const r = parseFastify(targetDir, excl, scan, candidateFiles);
if (r.endpoints.length) frameworksDetected.add("fastify");
code.push(...r.endpoints);
warnings.push(...r.warnings);
}
if (fw.nest) {
const r = parseNest(targetDir, excl, scan);
const r = parseNest(targetDir, excl, scan, candidateFiles);
if (r.endpoints.length) frameworksDetected.add("nest");
code.push(...r.endpoints);
warnings.push(...r.warnings);
}
if (fw.openapi) {
const r = parseOpenApi(targetDir, excl, scan);
const r = parseOpenApi(targetDir, excl, scan, candidateFiles);
if (r.endpoints.length) frameworksDetected.add("openapi");
spec.push(...r.endpoints);
warnings.push(...r.warnings);
Expand Down
8 changes: 5 additions & 3 deletions lib/parsers/express.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from "path";
import * as acorn from "acorn";
import * as walk from "acorn-walk";
import { readTextFile, walkFiles, relPath } from "../utils.mjs";
import { readTextFile, walkFiles, filterFilesByExtension, relPath } from "../utils.mjs";

const HTTP_METHODS = new Set([
"get", "post", "put", "delete", "patch", "head", "options", "all"
Expand Down Expand Up @@ -32,8 +32,10 @@ const TS_EXTS = new Set([".ts", ".tsx"]);
* name + MemberExpression last-property name + property names of inline
* array elements. Stored as `authMarkers` for the auth classifier.
*/
export function parseExpress(targetDir, excludePaths = [], scanOptions = {}) {
const files = walkFiles(targetDir, EXTS, excludePaths, scanOptions);
export function parseExpress(targetDir, excludePaths = [], scanOptions = {}, candidateFiles = null) {
const files = candidateFiles === null
? walkFiles(targetDir, EXTS, excludePaths, scanOptions)
: filterFilesByExtension(candidateFiles, EXTS);
const endpoints = [];
const warnings = [];

Expand Down
8 changes: 5 additions & 3 deletions lib/parsers/fastify.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as acorn from "acorn";
import * as walk from "acorn-walk";
import { readTextFile, walkFiles, relPath } from "../utils.mjs";
import { readTextFile, walkFiles, filterFilesByExtension, relPath } from "../utils.mjs";

const HTTP_METHODS = new Set([
"get", "post", "put", "delete", "patch", "head", "options", "all"
Expand All @@ -17,8 +17,10 @@ const EXTS = [".js", ".mjs", ".cjs"];
* - fastify.register(plugin, { prefix: '/api/v1' }) — prefix bindings are
* not propagated across files in v0.1; declared here for future use.
*/
export function parseFastify(targetDir, excludePaths = [], scanOptions = {}) {
const files = walkFiles(targetDir, EXTS, excludePaths, scanOptions);
export function parseFastify(targetDir, excludePaths = [], scanOptions = {}, candidateFiles = null) {
const files = candidateFiles === null
? walkFiles(targetDir, EXTS, excludePaths, scanOptions)
: filterFilesByExtension(candidateFiles, EXTS);
const endpoints = [];
const warnings = [];

Expand Down
8 changes: 5 additions & 3 deletions lib/parsers/nest.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readTextFile, walkFiles, relPath } from "../utils.mjs";
import { readTextFile, walkFiles, filterFilesByExtension, relPath } from "../utils.mjs";

const EXTS = [".ts", ".js", ".mjs", ".tsx"];

Expand Down Expand Up @@ -32,8 +32,10 @@ const METHOD_DECORATORS = [
* Unresolvable cases (controller path is a variable, dynamic decorator
* argument, missing class body): record with resolved: false.
*/
export function parseNest(targetDir, excludePaths = [], scanOptions = {}) {
const files = walkFiles(targetDir, EXTS, excludePaths, scanOptions);
export function parseNest(targetDir, excludePaths = [], scanOptions = {}, candidateFiles = null) {
const files = candidateFiles === null
? walkFiles(targetDir, EXTS, excludePaths, scanOptions)
: filterFilesByExtension(candidateFiles, EXTS);
const endpoints = [];
const warnings = [];

Expand Down
8 changes: 5 additions & 3 deletions lib/parsers/openapi.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as YAML from "yaml";
import { readTextFile, walkFiles, relPath } from "../utils.mjs";
import { readTextFile, walkFiles, filterFilesByExtension, relPath } from "../utils.mjs";

const EXTS = [".yaml", ".yml", ".json"];
const HTTP_METHODS = new Set(["get", "post", "put", "delete", "patch", "head", "options", "trace"]);
Expand All @@ -15,8 +15,10 @@ const HTTP_METHODS = new Set(["get", "post", "put", "delete", "patch", "head", "
* if a non-empty root `security` covers it. Operation-level `security: []`
* explicitly removes auth → OPEN. Missing security blocks → UNKNOWN.
*/
export function parseOpenApi(targetDir, excludePaths = [], scanOptions = {}) {
const files = walkFiles(targetDir, EXTS, excludePaths, scanOptions);
export function parseOpenApi(targetDir, excludePaths = [], scanOptions = {}, candidateFiles = null) {
const files = candidateFiles === null
? walkFiles(targetDir, EXTS, excludePaths, scanOptions)
: filterFilesByExtension(candidateFiles, EXTS);
const endpoints = [];
const warnings = [];
const specsDetected = [];
Expand Down
6 changes: 6 additions & 0 deletions lib/utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ export function walkFiles(targetDir, extensions, excludePatterns = [], scanOptio
return out;
}

/** Return the deterministic subset of an existing candidate index for `extensions`. */
export function filterFilesByExtension(files, extensions) {
const exts = new Set(extensions.map(e => e.toLowerCase()));
return files.filter(file => exts.has(path.extname(file).toLowerCase()));
}

export function readTextFile(file, maxBytes = DEFAULT_SCAN_OPTIONS.maxFileBytes) {
let st;
try {
Expand Down
47 changes: 46 additions & 1 deletion test/scan-safeguards.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import fs from "fs";
import os from "os";
import { spawnSync } from "child_process";
import { fileURLToPath } from "url";
import { runner, assertEq, assertIncludes, assertNotIncludes } from "./_runner.mjs";
import { runner, assertEq, assertDeepEq, assertIncludes, assertNotIncludes } from "./_runner.mjs";
import { buildInventory } from "../lib/inventory.mjs";
import { loadConfig } from "../lib/config.mjs";
import { parseExpress } from "../lib/parsers/express.mjs";
import { parseFastify } from "../lib/parsers/fastify.mjs";
import { parseNest } from "../lib/parsers/nest.mjs";
import { parseOpenApi } from "../lib/parsers/openapi.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..");
Expand Down Expand Up @@ -80,4 +86,43 @@ t.test("max-files limit exits clearly instead of hanging", () => {
assertNotIncludes(run.stderr, "Error:");
});

t.test("inventory performs one candidate walk for all enabled parsers", () => {
const dir = makeDir();
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "single-walk" }));
fs.writeFileSync(path.join(dir, "app.js"), "import express from 'express'; const app = express(); app.get('/ok', handler);\n");
fs.writeFileSync(path.join(dir, "controller.ts"), "@Controller('users') class Users { @Get(':id') one() {} }\n");
fs.writeFileSync(path.join(dir, "openapi.yaml"), "openapi: 3.0.0\npaths: {}\n");

const config = loadConfig(dir);
let progressCalls = 0;
config.scan = { ...config.scan, progressEvery: 1, onProgress: () => { progressCalls++; } };
const inventory = buildInventory(dir, config);

assertEq(progressCalls, 4, "one progress event per visited file, not per parser");
assertDeepEq(inventory.code.map(e => `${e.framework}:${e.method}:${e.path}`).sort(), [
"express:GET:/ok",
"nest:GET:/users/:id"
]);
});

t.test("shared index preserves endpoint keys on framework fixtures", () => {
const cases = [
["express", path.join(root, "test/fixtures/realworld-express"), parseExpress, "code"],
["fastify", path.join(root, "test/fixtures/fastify-app"), parseFastify, "code"],
["nest", path.join(root, "test/fixtures/nest-app"), parseNest, "code"],
["openapi", path.join(root, "test/fixtures/sample-app"), parseOpenApi, "spec"]
];
const keys = endpoints => endpoints.map(e =>
`${e.framework}|${e.method}|${e.path ?? "<unresolved>"}|${e.file ?? ""}|${e.line ?? ""}`
).sort();

for (const [framework, fixture, parser, bucket] of cases) {
const config = loadConfig(fixture);
config.frameworks = { express: false, fastify: false, nest: false, openapi: false, [framework]: true };
const legacyKeys = keys(parser(fixture, config.excludePaths, config.scan).endpoints);
const sharedKeys = keys(buildInventory(fixture, config)[bucket]);
assertDeepEq(sharedKeys, legacyKeys, `${framework} endpoint keys`);
}
});

t.finish();
Loading