Files
diegosouzapw--omniroute/scripts/check/check-route-guard-membership.ts
2026-07-13 13:39:12 +08:00

262 lines
11 KiB
JavaScript

#!/usr/bin/env node
// scripts/check/check-route-guard-membership.ts
// Quality gate: route-guard membership (CLAUDE.md Hard Rules #15 + #17).
//
// WHY: routes that spawn child processes (`npm install`, `node`, MITM/Playwright,
// worker_threads) MUST be classified loopback-only by `isLocalOnlyPath()` in
// src/server/authz/routeGuard.ts. Loopback enforcement runs unconditionally
// BEFORE any auth check — so a leaked JWT over a tunnel cannot reach a spawn.
// A single spawn-capable `route.ts` that `isLocalOnlyPath()` does NOT match is an
// RCE-via-tunnel hole (the GHSA-fhh6-4qxv-rpqj surface the LOCAL_ONLY tier closes).
//
// This gate enumerates every `route.ts` under the spawn-capable prefixes and
// asserts each resolved URL path is classified local-only by the REAL predicate.
//
// Ratchet: any pre-existing unclassified route is frozen in KNOWN_UNCLASSIFIED
// with a justification so the gate exits 0 today; only NEW spawn-capable routes
// that slip past the guard fail. KNOWN_UNCLASSIFIED is empty today (clean
// baseline) — keep it that way; an entry here is a documented security debt.
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { pathToFileURL } from "node:url";
import { isLocalOnlyPath } from "@/server/authz/routeGuard.ts";
// Inline stale-allowlist helper (mirrors scripts/check/lib/allowlist.mjs).
// The TypeScript gate cannot import the .mjs helper directly; keep this in sync.
function assertNoStaleEntries(
allowlist: string[] | Record<string, string>,
liveItems: string[],
gateName: string
): void {
const liveSet = new Set(liveItems);
const keys = Array.isArray(allowlist) ? allowlist : Object.keys(allowlist);
const stale = keys.filter((k) => !liveSet.has(k));
if (stale.length > 0) {
console.error(
`[${gateName}] ${stale.length} entrada(s) obsoleta(s) na allowlist ` +
`— a violação foi corrigida; REMOVA a entrada para travar a correção:\n` +
stale.map((e) => ` ✗ ${e}`).join("\n")
);
process.exitCode = 1;
}
}
// Spawn-capable route roots (relative to repo root). Mirrors the spawn-capable
// prefixes documented in routeGuard.ts (SPAWN_CAPABLE_PREFIXES) and CLAUDE.md
// Hard Rules #15/#17 for the dirs that physically exist under src/app/api/.
export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray<string> = [
"src/app/api/services",
"src/app/api/mcp",
"src/app/api/cli-tools/runtime",
"src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17)
];
// Frozen pre-existing exceptions: spawn-capable routes NOT yet classified
// local-only. Each entry is a documented security debt — the route is reachable
// past the loopback gate. Empty today (every spawn-capable route is classified).
// Adding an entry here REQUIRES a justification + a follow-up to classify it in
// LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS (src/server/authz/routeGuard.ts).
export const KNOWN_UNCLASSIFIED: Record<string, string> = {};
/**
* Map a Next.js App Router `route.ts` file path to the URL path the route
* serves, in the exact shape `isLocalOnlyPath()` expects (a plain `/api/...`
* path). Dynamic `[param]` segments become a concrete `_param_` placeholder —
* `isLocalOnlyPath` matches prefixes via `startsWith`, so any non-empty segment
* satisfies the classification (e.g. `/api/services/_name_/logs` still starts
* with `/api/services/`).
*/
export function routeFileToApiPath(routeFile: string): string {
return routeFile
.replace(/\\/g, "/")
.replace(/^src\/app/, "")
.replace(/\/route\.ts$/, "")
.replace(/\[([^\]]+)\]/g, "_$1_");
}
/**
* Pure matching core: given resolved URL paths, a classifier predicate, and an
* allowlist, return the paths that are NEITHER classified local-only NOR
* allowlisted (input order preserved). These are the RCE-via-tunnel holes.
*/
export function findUnclassifiedSpawnRoutes(
apiPaths: string[],
isLocalOnly: (path: string) => boolean,
allowlist: Record<string, string>
): string[] {
return apiPaths.filter((p) => !isLocalOnly(p) && !(p in allowlist));
}
// --- 6A.8: source-based spawn detection ---
// Patterns that indicate a route.ts spawns child processes.
// Matches: import from "child_process" / "node:child_process" / "worker_threads" /
// "node:worker_threads" or a spawn( / execFile( / exec( call.
const SPAWN_SOURCE_RE =
/\b(?:from\s+["'](?:node:)?(?:child_process|worker_threads)["']|require\s*\(\s*["'](?:node:)?(?:child_process|worker_threads)["']\s*\)|spawn\s*\(|execFile\s*\(|execFileSync\s*\(|exec\s*\()/;
/**
* Returns true if the given source text of a route.ts file directly imports
* from child_process / worker_threads or calls spawn()/execFile()/exec().
* Used by the 6A.8 source-scan subcheck to find spawn-capable routes outside
* the static SPAWN_CAPABLE_ROUTE_ROOTS list.
*/
export function isSpawnCapableSource(source: string): boolean {
return SPAWN_SOURCE_RE.test(source);
}
/**
* Walk all route.ts files under src/app/api/ from repoRoot and return those whose
* source matches isSpawnCapableSource. Returns relative paths (forward slashes).
*/
export function findSpawnCapableRoutes(repoRoot: string): string[] {
const apiDir = join(repoRoot, "src", "app", "api");
const out: string[] = [];
function walk(dir: string): void {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry);
try {
if (statSync(full).isDirectory()) {
walk(full);
} else if (entry === "route.ts") {
const src = readFileSync(full, "utf8");
if (isSpawnCapableSource(src)) {
out.push(relative(repoRoot, full).replace(/\\/g, "/"));
}
}
} catch {
// skip unreadable
}
}
}
walk(apiDir);
return out.sort();
}
/**
* 6A.8: pre-existing spawn-capable route.ts files that live OUTSIDE
* SPAWN_CAPABLE_ROUTE_ROOTS but are NOT yet classified local-only.
* Each entry is documented security debt (Hard Rules #15/#17):
* the route can spawn child processes and is reachable past the loopback gate.
*
* TODO(6A.8): classify these in LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS
* or add specific auth-only enforcement (no loopback, but require-auth before spawn).
* Adding an entry here requires a justification + follow-up issue.
*/
export const KNOWN_UNCLASSIFIED_SOURCE_SPAWN: Record<string, string> = {
// RESOLVED (6A.8 P1, 2026-06-13): /api/system/version and /api/db-backups/exportAll
// are now classified in LOCAL_ONLY_API_PREFIXES (loopback-enforced before auth).
// The stale-enforcement guard requires this set to stay empty until a NEW
// unclassified spawn-capable route appears.
// NOTE: cli-tools/antigravity-mitm/route.ts triggers child_process INDIRECTLY via
// dynamic import to @/mitm/manager.runtime, but does NOT directly import child_process.
// The source-scan gate covers DIRECT imports/calls only; this route is NOT in the
// spawn-capable set by source analysis. Kept as a comment for documentation but
// NOT in the allowlist (stale-enforcement would flag it). The route has requireCliToolsAuth()
// for auth gating; the underlying spawn happens in mitm/manager.runtime.
// If /api/cli-tools/ is ever added to LOCAL_ONLY_API_PREFIXES, revisit this note.
};
/** Recursively collect every `route.ts` under `dir` (returns [] if dir absent). */
function collectRouteFiles(dir: string): string[] {
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return []; // dir does not exist — nothing to enumerate
}
const out: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...collectRouteFiles(full));
} else if (entry === "route.ts") {
out.push(full);
}
}
return out;
}
function main(): void {
const cwd = process.cwd();
// --- Subcheck 1 (original): SPAWN_CAPABLE_ROUTE_ROOTS ---
const apiPaths = SPAWN_CAPABLE_ROUTE_ROOTS.flatMap(collectRouteFiles)
.map(routeFileToApiPath)
.sort();
const unclassified = findUnclassifiedSpawnRoutes(apiPaths, isLocalOnlyPath, KNOWN_UNCLASSIFIED);
// --- Subcheck 2 (6A.8): source-based scan — ALL route.ts files ---
// Find every route.ts that imports child_process / worker_threads and verify it is
// either classified local-only or frozen in KNOWN_UNCLASSIFIED_SOURCE_SPAWN.
const spawnCapableFiles = findSpawnCapableRoutes(cwd);
// Stale-enforcement: if a route was fixed (no longer spawn-capable, or was classified),
// the KNOWN_UNCLASSIFIED_SOURCE_SPAWN entry must be removed.
assertNoStaleEntries(
KNOWN_UNCLASSIFIED_SOURCE_SPAWN,
spawnCapableFiles,
"route-guard-membership/source-spawn"
);
// Find spawn-capable routes outside SPAWN_CAPABLE_ROUTE_ROOTS that are not classified
// local-only and not in the source-spawn allowlist.
const unclassifiedSourceSpawn = spawnCapableFiles.filter((rel) => {
const apiPath = routeFileToApiPath(rel);
// Already covered by subcheck 1 (in a SPAWN_CAPABLE_ROUTE_ROOT)? Skip.
if (SPAWN_CAPABLE_ROUTE_ROOTS.some((root) => rel.startsWith(root + "/"))) return false;
// In the source-spawn allowlist? Skip.
if (rel in KNOWN_UNCLASSIFIED_SOURCE_SPAWN) return false;
// Classified local-only? Skip.
if (isLocalOnlyPath(apiPath)) return false;
return true;
});
// Report
let failed = false;
if (unclassified.length) {
console.error(
`[route-guard-membership] CRITICAL — ${unclassified.length} spawn-capable route(s) in SPAWN_CAPABLE_ROUTE_ROOTS NOT classified local-only (RCE-via-tunnel risk, Hard Rules #15/#17):\n` +
unclassified.map((p) => ` ✗ ${p}`).join("\n") +
`\n → add a matching prefix to LOCAL_ONLY_API_PREFIXES or a pattern to LOCAL_ONLY_API_PATTERNS in src/server/authz/routeGuard.ts, or freeze in KNOWN_UNCLASSIFIED with justification.`
);
failed = true;
}
if (unclassifiedSourceSpawn.length) {
console.error(
`[route-guard-membership] CRITICAL — ${unclassifiedSourceSpawn.length} route.ts file(s) contain child_process/worker_threads but are NOT classified local-only (Hard Rules #15/#17):\n` +
unclassifiedSourceSpawn.map((p) => ` ✗ ${p} (${routeFileToApiPath(p)})`).join("\n") +
`\n → classify in LOCAL_ONLY_API_PREFIXES / LOCAL_ONLY_API_PATTERNS, or freeze in KNOWN_UNCLASSIFIED_SOURCE_SPAWN with justification.`
);
failed = true;
}
if (failed) process.exit(1);
if (process.exitCode === 1) return; // stale entries already logged
console.log(
`[route-guard-membership] OK — ` +
`${apiPaths.length} route(s) in ${SPAWN_CAPABLE_ROUTE_ROOTS.length} root(s) all local-only; ` +
`${spawnCapableFiles.length} source-spawn route(s) scanned, ` +
`${Object.keys(KNOWN_UNCLASSIFIED_SOURCE_SPAWN).length} frozen as security debt, ` +
`0 new gaps`
);
// Explicit exit: importing routeGuard.ts pulls in runtime settings, which opens
// the SQLite DB and starts a background health-check timer that would otherwise
// keep the process alive. The gate's work is done — exit cleanly.
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();