diff --git a/README.md b/README.md index 01eda9d..1417f1c 100644 --- a/README.md +++ b/README.md @@ -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 deterministic — same 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. @@ -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 @@ -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. diff --git a/lib/inventory.mjs b/lib/inventory.mjs index c27ab45..1505760 100644 --- a/lib/inventory.mjs +++ b/lib/inventory.mjs @@ -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), @@ -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); diff --git a/lib/parsers/express.mjs b/lib/parsers/express.mjs index 8843879..037f3e1 100644 --- a/lib/parsers/express.mjs +++ b/lib/parsers/express.mjs @@ -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" @@ -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 = []; diff --git a/lib/parsers/fastify.mjs b/lib/parsers/fastify.mjs index a850afe..3177b2a 100644 --- a/lib/parsers/fastify.mjs +++ b/lib/parsers/fastify.mjs @@ -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" @@ -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 = []; diff --git a/lib/parsers/nest.mjs b/lib/parsers/nest.mjs index ecdc76d..a062c35 100644 --- a/lib/parsers/nest.mjs +++ b/lib/parsers/nest.mjs @@ -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"]; @@ -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 = []; diff --git a/lib/parsers/openapi.mjs b/lib/parsers/openapi.mjs index 0fe4daf..d1eef3d 100644 --- a/lib/parsers/openapi.mjs +++ b/lib/parsers/openapi.mjs @@ -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"]); @@ -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 = []; diff --git a/lib/utils.mjs b/lib/utils.mjs index fc87560..4f3d80d 100644 --- a/lib/utils.mjs +++ b/lib/utils.mjs @@ -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 { diff --git a/test/scan-safeguards.mjs b/test/scan-safeguards.mjs index 90a2f0e..7574df7 100644 --- a/test/scan-safeguards.mjs +++ b/test/scan-safeguards.mjs @@ -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, ".."); @@ -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 ?? ""}|${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();