169 lines
5.9 KiB
JavaScript
169 lines
5.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/* eslint-disable no-console */
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { openApiDocument } from "../apps/api/src/openapi.js";
|
|
|
|
const httpMethods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]);
|
|
const publicOperationAllowlist = new Set([
|
|
"GET /healthz",
|
|
"GET /v1/health",
|
|
"GET /readyz",
|
|
"GET /status",
|
|
"GET /v1/docs",
|
|
"GET /v1/openapi.json",
|
|
"GET /v1/analytics/public",
|
|
"POST /v1/auth/login",
|
|
"POST /v1/billing/mollie/webhooks",
|
|
]);
|
|
// OpenAPI has no Security Scheme location for credentials embedded in a JSON
|
|
// request body. These operations document the write-only credential in their
|
|
// body schema and must not claim an unrelated header-based auth mechanism.
|
|
const requestBodyCredentialOperationAllowlist = new Set([
|
|
"POST /v1/webhooks/telephony/aircall",
|
|
"POST /v1/webhooks/admin-telephony/aircall",
|
|
]);
|
|
const optionalAnonymousAllowlist = new Set(["POST /v1/api-keys/validate"]);
|
|
|
|
export type OpenApiDocument = {
|
|
components?: {
|
|
securitySchemes?: Record<string, unknown>;
|
|
};
|
|
paths?: Record<string, Record<string, unknown>>;
|
|
};
|
|
|
|
export type GuardResult = {
|
|
ok: boolean;
|
|
violations: string[];
|
|
};
|
|
|
|
type OperationObject = {
|
|
operationId?: unknown;
|
|
security?: unknown;
|
|
"x-api-key-permissions"?: unknown;
|
|
};
|
|
|
|
type OperationEntry = {
|
|
method: string;
|
|
path: string;
|
|
operation: OperationObject;
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function operationContext({ method, path, operation }: OperationEntry) {
|
|
const operationId = typeof operation.operationId === "string" && operation.operationId.length > 0 ? operation.operationId : "<missing operationId>";
|
|
return `${method.toUpperCase()} ${path} ${operationId}`;
|
|
}
|
|
|
|
function listOperations(document: OpenApiDocument): OperationEntry[] {
|
|
const operations: OperationEntry[] = [];
|
|
const paths = isRecord(document.paths) ? document.paths : {};
|
|
|
|
for (const [path, pathItem] of Object.entries(paths)) {
|
|
if (!isRecord(pathItem)) continue;
|
|
|
|
for (const [method, operation] of Object.entries(pathItem)) {
|
|
const normalizedMethod = method.toLowerCase();
|
|
if (!httpMethods.has(normalizedMethod) || !isRecord(operation)) continue;
|
|
operations.push({ method: normalizedMethod, path, operation: operation as OperationObject });
|
|
}
|
|
}
|
|
|
|
return operations;
|
|
}
|
|
|
|
function securityRequirementSchemeNames(security: unknown): string[] {
|
|
if (!Array.isArray(security)) return [];
|
|
|
|
return security.flatMap((requirement) => {
|
|
if (!isRecord(requirement)) return [];
|
|
return Object.keys(requirement);
|
|
});
|
|
}
|
|
|
|
function hasEmptySecurityRequirement(security: unknown) {
|
|
return Array.isArray(security) && security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length === 0);
|
|
}
|
|
|
|
function hasNonEmptySecurityRequirement(security: unknown) {
|
|
return Array.isArray(security) && security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length > 0);
|
|
}
|
|
|
|
export function validateOpenApiSecurity(document: OpenApiDocument = openApiDocument): GuardResult {
|
|
const declaredSchemes = new Set(Object.keys(document.components?.securitySchemes ?? {}));
|
|
const violations: string[] = [];
|
|
const operations = listOperations(document);
|
|
|
|
if (!isRecord(document.paths) || Object.keys(document.paths).length === 0) {
|
|
violations.push("OpenAPI document must define at least one path");
|
|
}
|
|
|
|
if (operations.length === 0) {
|
|
violations.push("OpenAPI document must define at least one operation");
|
|
}
|
|
|
|
for (const schemeName of ["bearerAuth", "apiKeyHeader"]) {
|
|
if (!declaredSchemes.has(schemeName)) {
|
|
violations.push(`OpenAPI document must declare security scheme '${schemeName}'`);
|
|
}
|
|
}
|
|
|
|
for (const entry of operations) {
|
|
const context = operationContext(entry);
|
|
const allowlistKey = `${entry.method.toUpperCase()} ${entry.path}`;
|
|
const { operation } = entry;
|
|
const schemeNames = securityRequirementSchemeNames(operation.security);
|
|
|
|
for (const schemeName of schemeNames) {
|
|
if (!declaredSchemes.has(schemeName)) {
|
|
violations.push(`${context}: references undeclared security scheme '${schemeName}'`);
|
|
}
|
|
}
|
|
|
|
if (publicOperationAllowlist.has(allowlistKey)) {
|
|
if (operation.security !== undefined && (!Array.isArray(operation.security) || operation.security.length !== 0)) {
|
|
violations.push(`${context}: public allowlisted operation must omit security or declare security: []`);
|
|
}
|
|
} else if (requestBodyCredentialOperationAllowlist.has(allowlistKey)) {
|
|
if (operation.security !== undefined && (!Array.isArray(operation.security) || operation.security.length !== 0)) {
|
|
violations.push(`${context}: request-body credential operation must omit security or declare security: []`);
|
|
}
|
|
} else {
|
|
if (!hasNonEmptySecurityRequirement(operation.security)) {
|
|
violations.push(`${context}: protected operation must declare non-empty security`);
|
|
}
|
|
|
|
if (hasEmptySecurityRequirement(operation.security) && !optionalAnonymousAllowlist.has(allowlistKey)) {
|
|
violations.push(`${context}: anonymous security requirement is only allowed for optional-auth allowlisted operations`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ok: violations.length === 0, violations };
|
|
}
|
|
|
|
export function renderOpenApiSecurityGuardResult(result: GuardResult) {
|
|
if (result.ok) return "PASS openapi-security: all operation security contracts are valid";
|
|
|
|
return ["FAIL openapi-security:", ...result.violations.map((violation) => `- ${violation}`)].join("\n");
|
|
}
|
|
|
|
function main() {
|
|
const result = validateOpenApiSecurity(openApiDocument);
|
|
const output = renderOpenApiSecurityGuardResult(result);
|
|
if (result.ok) {
|
|
console.log(output);
|
|
return;
|
|
}
|
|
|
|
console.error(output);
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
main();
|
|
}
|