/** * scanUnscopedCredentials — ported from * `scripts/guard-no-unscoped-credentials.mjs`. * * Refuse to let any caller invoke `resolveCredential` / `hasCredential` / * `saveCredential` / `deleteCredential` with only one argument. The * contract requires a second argument carrying the per-user / per-org * context object — `(key, { userEmail, orgId })` — so the credential * lookup is scoped to the requesting principal. * * Generic guard, shipped as-is: the detection regex, arg-balancing parser, * and marker semantics are unchanged from the monorepo original. The one * difference from the monorepo script is scan scope: the original restricts * its walk to `packages/`, `templates/`, `app/` — a monorepo-specific * optimization that also happens to exclude its own dotfile/doc trees. A * generated app's source lives at `actions/`, `app/`, `server/` (no * `packages/`/`templates/` wrapper — see report 005's "Path resolution"), * so keeping that literal subtree filter would silently skip `actions/` * and `server/`. This port scans the whole app root (still respecting * `SKIP_DIRS`) instead, which is a superset of the original's coverage and * has no false-negative risk for a single-app tree. * * Opt-out (same line only): * resolveCredential(key) // guard:allow-unscoped-credential — short reason */ import { lineColForOffset, readFileSafe, relPosix, walk, } from "./scan-utils.js"; import type { GuardFinding, GuardResult, GuardScanOptions } from "./types.js"; const FUNCTIONS = [ "resolveCredential", "hasCredential", "saveCredential", "deleteCredential", ]; const FUNC_NAME_RE = new RegExp( `(? 0) { templateDepth--; i++; continue; } i++; continue; } i++; } return null; } export function scanUnscopedCredentials( options: GuardScanOptions, ): GuardResult { const { root } = options; const findings: GuardFinding[] = []; for (const file of walk(root)) { if (!/\.(ts|tsx|mts|cts|js|mjs|cjs)$/.test(file)) continue; if (file.endsWith(".d.ts")) continue; const rel = relPosix(root, file); if (FILE_ALLOWLIST.has(rel)) continue; const contents = readFileSafe(file); if (contents === null) continue; let anyHit = false; for (const fn of FUNCTIONS) { if (contents.includes(fn + "(") || contents.includes(fn + " (")) { anyHit = true; break; } } if (!anyHit) continue; const lines = contents.split("\n"); FUNC_NAME_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = FUNC_NAME_RE.exec(contents)) !== null) { const fnName = m[1]; const openParenIdx = m.index + m[0].length - 1; const before = contents.slice(Math.max(0, m.index - 30), m.index); if ( /\bfunction\s+$/.test(before) || /\bexport\s+(default\s+)?(async\s+)?function\s+$/.test(before) || /\b(const|let|var)\s+$/.test(before) ) { continue; } const { line } = lineColForOffset(contents, m.index); const lineText = lines[line - 1] ?? ""; const trimmed = lineText.trimStart(); if ( trimmed.startsWith("*") || trimmed.startsWith("//") || trimmed.startsWith("/*") ) { continue; } const analysis = analyzeArgs(contents, openParenIdx); if (!analysis) continue; if (analysis.topLevelCommas >= 1) continue; if (OPT_OUT_MARKER.test(lineText)) continue; findings.push({ file: rel, line, message: `${fnName}(...) called with only one argument — must pass a context object: ${fnName}(key, { userEmail, orgId })`, }); } } return { name: "no-unscoped-credentials", findings }; }