diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..ae25238 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -21,6 +21,7 @@ import hashDisclosureScan from "./hash-disclosure"; import jsonHtmlResponseScan from "./json-html-response"; import missingContentTypeScan from "./missing-content-type"; import openRedirectScan from "./open-redirect"; +import openApiDefinitionScan from "./openapi-definition"; import pathTraversalScan from "./path-traversal"; import phpinfoScan from "./phpinfo"; import privateIpDisclosureScan from "./private-ip-disclosure"; @@ -59,6 +60,7 @@ export const Checks = { HASH_DISCLOSURE: "hash-disclosure", JSON_HTML_RESPONSE: "json-html-response", MISSING_CONTENT_TYPE: "missing-content-type", + OPENAPI_DEFINITION_FOUND: "openapi-definition-found", OPEN_REDIRECT: "open-redirect", PATH_TRAVERSAL: "path-traversal", PHPINFO: "phpinfo", @@ -100,6 +102,7 @@ export const checks = [ jsonHtmlResponseScan, missingContentTypeScan, openRedirectScan, + openApiDefinitionScan, pathTraversalScan, phpinfoScan, privateIpDisclosureScan, diff --git a/packages/backend/src/checks/openapi-definition/index.spec.ts b/packages/backend/src/checks/openapi-definition/index.spec.ts new file mode 100644 index 0000000..e6ac6f2 --- /dev/null +++ b/packages/backend/src/checks/openapi-definition/index.spec.ts @@ -0,0 +1,68 @@ +import { createMockRequest, createMockResponse, runCheck } from "engine"; +import { describe, expect, it } from "vitest"; + +import openApiCheck from "./index"; + +const runOpenApiCheck = async (body: string): Promise => { + const request = createMockRequest({ + id: "req-openapi", + host: "example.com", + method: "GET", + path: "/openapi.json", + headers: { Host: ["example.com"] }, + }); + + const response = createMockResponse({ + id: "res-openapi", + code: 200, + headers: { "content-type": ["application/json"] }, + body, + }); + + const execution = await runCheck(openApiCheck, [{ request, response }]); + return execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? []; +}; + +describe("OpenAPI definition check", () => { + it("detects JSON OpenAPI documents", async () => { + const findings = await runOpenApiCheck( + JSON.stringify({ + openapi: "3.0.1", + info: { title: "API", version: "1.0" }, + paths: {}, + }), + ); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + name: "OpenAPI definition exposed", + severity: "medium", + }); + }); + + it("detects YAML OpenAPI documents", async () => { + const findings = await runOpenApiCheck( + [ + "openapi: 3.0.2", + "info:", + " title: API", + "paths:", + " /users:", + " get:", + " responses:", + " '200':", + " description: OK", + ].join("\n"), + ); + + expect(findings).toHaveLength(1); + }); + + it("does not flag unrelated JSON", async () => { + const findings = await runOpenApiCheck( + JSON.stringify({ message: "hello" }), + ); + + expect(findings).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/checks/openapi-definition/index.ts b/packages/backend/src/checks/openapi-definition/index.ts new file mode 100644 index 0000000..cee12c4 --- /dev/null +++ b/packages/backend/src/checks/openapi-definition/index.ts @@ -0,0 +1,131 @@ +import { defineCheck, done, Severity } from "engine"; + +import { Tags } from "../../types"; +import { keyStrategy } from "../../utils"; + +type FindingData = { + format: "json" | "yaml"; + version?: string; +}; + +const YAML_PATTERN = /\b(openapi|swagger)\s*:\s*["']?([\d.]+)["']?/i; + +const parseOpenApiJson = (body: string): FindingData | undefined => { + try { + const json = JSON.parse(body) as Record; + + if (json === null || typeof json !== "object") { + return undefined; + } + + const version = + typeof json.openapi === "string" + ? json.openapi + : typeof json.swagger === "string" + ? json.swagger + : undefined; + + if (version === undefined) { + return undefined; + } + + if ( + json.paths !== undefined && + typeof json.paths === "object" && + json.info !== undefined + ) { + return { format: "json", version }; + } + } catch { + // Not JSON, ignore + } + + return undefined; +}; + +const parseOpenApiYaml = (body: string): FindingData | undefined => { + const match = body.match(YAML_PATTERN); + if (match === null) { + return undefined; + } + + const version = match[2]; + if (version === undefined) { + return undefined; + } + + if (/\bpaths\s*:\s*/i.test(body) && /\binfo\s*:\s*/i.test(body)) { + return { format: "yaml", version }; + } + + return undefined; +}; + +const buildDescription = (data: FindingData): string => { + const formatText = data.format === "json" ? "JSON" : "YAML"; + const versionText = + data.version !== undefined ? ` (version ${data.version})` : ""; + + return [ + `An OpenAPI definition${versionText} was returned in the response (${formatText}).`, + "", + "OpenAPI/Swagger documentation often exposes full API surface area and can assist attackers in discovering sensitive endpoints or understanding authentication flows.", + "", + "Restrict access to API documentation in production or ensure it does not contain sensitive endpoints.", + ].join("\n"); +}; + +export default defineCheck>(({ step }) => { + step("detectOpenApiDefinition", (state, context) => { + const { response } = context.target; + + if (response === undefined) { + return done({ state }); + } + + const body = response.getBody()?.toText(); + if (body === undefined || body.length === 0) { + return done({ state }); + } + + const findingData = parseOpenApiJson(body) ?? parseOpenApiYaml(body); + + if (findingData === undefined) { + return done({ state }); + } + + return done({ + state, + findings: [ + { + name: "OpenAPI definition exposed", + description: buildDescription(findingData), + severity: Severity.MEDIUM, + correlation: { + requestID: context.target.request.getId(), + locations: [], + }, + }, + ], + }); + }); + + return { + metadata: { + id: "openapi-definition-found", + name: "OpenAPI definition exposed", + description: + "Detects responses that expose OpenAPI/Swagger service definitions.", + type: "passive", + tags: [Tags.INFORMATION_DISCLOSURE], + severities: [Severity.MEDIUM], + aggressivity: { + minRequests: 0, + maxRequests: 0, + }, + }, + initState: () => ({}), + dedupeKey: keyStrategy().withHost().withPath().build(), + when: (target) => target.response !== undefined, + }; +}); diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..1dd60d5 100644 --- a/packages/backend/src/stores/config.ts +++ b/packages/backend/src/stores/config.ts @@ -188,6 +188,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.OPENAPI_DEFINITION_FOUND, + enabled: true, + }, ], }, {