Compare commits

...

1 Commits

Author SHA1 Message Date
diegosouzapw
d9b70d3c6a fix(ci): openapi-security-tiers must read ALWAYS_PROTECTED_API_PATTERNS (base-red #12581)
`isAlwaysProtectedPath()` ORs two arrays — ALWAYS_PROTECTED_API_PATHS and
ALWAYS_PROTECTED_API_PATTERNS — but the gate parsed only the first, so every
route protected exclusively by a regex was reported as an annotation
mismatch. Four routes are in that position and turn the branch red:

  POST /api/providers/{id}/{claude,codex}-auth/{export,apply-local}

All four are covered by the pattern added with the credential-export
hard-gate, `/^\/api\/providers\/[^/]+\/(claude|codex)-auth\/(export|apply-local)\/?$/`,
and a runtime probe over `isAlwaysProtectedPath()` returns true for all four —
the routes were never unprotected, the checker was blind to half its input.

This is the residual half of the same defect #12350 fixed for the LOCAL_ONLY
tier, which is why that arm already reads both arrays.

- parse ALWAYS_PROTECTED_API_PATTERNS and include it in the fatal parse guard,
  so a future formatting change fails loudly instead of silently reverting to
  false positives
- add `coveredByAlwaysProtected()` mirroring `coveredByLocalOnly()`, both
  concretizing `{param}` before matching
- name both arrays in the error text, so the next mismatch says where to look

Regression test runs the gate as a subprocess (no routeGuard import, so the
unit suite gains no DB handle): red before the fix with all four routes
listed, green after.
2026-09-03 21:04:43 -03:00
2 changed files with 60 additions and 11 deletions

View File

@@ -106,16 +106,19 @@ function parsePatterns(name) {
const LOCAL_ONLY_PREFIXES = parsePrefixes("LOCAL_ONLY_API_PREFIXES");
const LOCAL_ONLY_PATTERNS = parsePatterns("LOCAL_ONLY_API_PATTERNS");
const ALWAYS_PROTECTED_PATHS = parsePrefixes("ALWAYS_PROTECTED_API_PATHS");
const ALWAYS_PROTECTED_PATTERNS = parsePatterns("ALWAYS_PROTECTED_API_PATTERNS");
if (
LOCAL_ONLY_PREFIXES.length === 0 ||
LOCAL_ONLY_PATTERNS.length === 0 ||
ALWAYS_PROTECTED_PATHS.length === 0
ALWAYS_PROTECTED_PATHS.length === 0 ||
ALWAYS_PROTECTED_PATTERNS.length === 0
) {
console.error(
`[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants ` +
`(prefixes=${LOCAL_ONLY_PREFIXES.length}, patterns=${LOCAL_ONLY_PATTERNS.length}, ` +
`alwaysProtected=${ALWAYS_PROTECTED_PATHS.length})`
`alwaysProtected=${ALWAYS_PROTECTED_PATHS.length}, ` +
`alwaysProtectedPatterns=${ALWAYS_PROTECTED_PATTERNS.length})`
);
process.exit(1);
}
@@ -135,6 +138,19 @@ function coveredByLocalOnly(pathStr) {
return matchesPrefix(concrete) || LOCAL_ONLY_PATTERNS.some((re) => re.test(concrete));
}
// Same two-array shape as the LOCAL_ONLY tier: routeGuard protects a path when
// EITHER list matches (`isAlwaysProtectedPath` ORs them), so reading only the
// prefix array reports every regex-covered route as an annotation mismatch.
// That is what happened to the four `{claude,codex}-auth/{export,apply-local}`
// routes, which ALWAYS_PROTECTED_API_PATTERNS has always covered.
function coveredByAlwaysProtected(pathStr) {
const concrete = concretize(pathStr);
return (
ALWAYS_PROTECTED_PATHS.some((p) => concrete === p || concrete.startsWith(`${p}/`)) ||
ALWAYS_PROTECTED_PATTERNS.some((re) => re.test(concrete))
);
}
const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8"));
const paths = raw.paths || {};
const errors = [];
@@ -151,16 +167,11 @@ for (const [pathStr, methods] of Object.entries(paths)) {
);
}
if (spec["x-always-protected"] === true) {
const matchesPath = ALWAYS_PROTECTED_PATHS.some(
(p) => pathStr === p || pathStr.startsWith(`${p}/`)
if (spec["x-always-protected"] === true && !coveredByAlwaysProtected(pathStr)) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT covered by ` +
`ALWAYS_PROTECTED_API_PATHS or ALWAYS_PROTECTED_API_PATTERNS`
);
if (!matchesPath) {
errors.push(
`${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT in ` +
`ALWAYS_PROTECTED_API_PATHS [${ALWAYS_PROTECTED_PATHS.join(", ")}]`
);
}
}
}
}

View File

@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const GATE = join(ROOT, "scripts", "check", "check-openapi-security-tiers.mjs");
function runGate(): { code: number; out: string } {
try {
const out = execFileSync(process.execPath, [GATE], {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
return { code: 0, out };
} catch (err) {
const e = err as { status?: number; stdout?: string; stderr?: string };
return { code: e.status ?? 1, out: `${e.stdout ?? ""}${e.stderr ?? ""}` };
}
}
// routeGuard protects a path when EITHER list matches — `isAlwaysProtectedPath`
// ORs ALWAYS_PROTECTED_API_PATHS with ALWAYS_PROTECTED_API_PATTERNS. The gate
// used to read only the prefix array, so every regex-covered route was reported
// as an annotation mismatch: the four `{claude,codex}-auth/{export,apply-local}`
// routes turned release/v3.8.51 red while being correctly protected at runtime.
// Same defect class the LOCAL_ONLY arm already had (#12350).
test("openapi-security-tiers accepts routes covered only by ALWAYS_PROTECTED_API_PATTERNS", () => {
const { code, out } = runGate();
assert.ok(
!/has x-always-protected but is NOT/.test(out),
`gate reported an always-protected route as uncovered:\n${out}`
);
assert.equal(code, 0, `gate must pass on a clean tree, got exit ${code}:\n${out}`);
});