Spaces:
Runtime error
Runtime error
| // scripts/check/check-deps.mjs | |
| // Gate anti-slopsquatting: toda dependência em QUALQUER package.json do repo deve | |
| // estar numa allowlist commitada (dependency-allowlist.json). Uma dep nova exige | |
| // adição EXPLÍCITA à allowlist — assim um agente não consegue introduzir um pacote | |
| // alucinado/typosquatted silenciosamente (CSA 2026: 19,7% do código IA cita pacotes | |
| // inexistentes; 43% dos nomes alucinados reaparecem, registráveis por atacantes). | |
| // A revisão humana ao adicionar à allowlist é o ponto de controle. | |
| // | |
| // 6A.8: Expandido de 2 manifests hardcoded (package.json + electron/package.json) | |
| // para descoberta automática de TODOS os package.json do repo, excluindo: | |
| // - node_modules/ (dep tree) | |
| // - .next/, .build/, dist/, dist-electron/ (build artefatos) | |
| // - .claude/ (worktrees de agentes) | |
| // - _references/, _mono_repo/ (código de referência não pertencente ao repo) | |
| // Isso garante que workspaces novos (opencode-plugin, opencode-provider, open-sse, etc.) | |
| // sejam automaticamente cobertos sem edição do script. | |
| // | |
| // Task 7.8: Anti-slopsquatting completo — para deps NOVAS (fora da allowlist), | |
| // dois sub-checks adicionais ANTES de falhar: | |
| // (a) a dep EXISTE no npm registry (npm view <pkg> version) | |
| // (b) foi publicada há ≥72h (age-cooldown contra typosquatting de nomes alucinados) | |
| // Ambas as chamadas são tolerantes a falha de rede: se o registry estiver inacessível, | |
| // emite aviso mas não bloqueia — o gate principal (allowlist) ainda captura a dep nova. | |
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| import { pathToFileURL } from "node:url"; | |
| import { execFileSync } from "node:child_process"; | |
| import { assertNoStale } from "./lib/allowlist.mjs"; | |
| const ROOT = process.cwd(); | |
| const ALLOWLIST_PATH = path.join(ROOT, "config/quality/dependency-allowlist.json"); | |
| // Directories to exclude when discovering package.json files. | |
| // Using a set of path segment prefixes (relative to ROOT, forward slashes). | |
| const EXCLUDED_SEGMENTS = new Set([ | |
| "node_modules", | |
| ".next", | |
| ".build", | |
| "dist", | |
| "dist-electron", | |
| ".claude", | |
| "_references", | |
| "_mono_repo", | |
| ]); | |
| /** | |
| * 6A.8: Discover all package.json files in the repo, excluding build artefacts, | |
| * reference code, and agent worktrees. Returns relative paths (forward slashes). | |
| */ | |
| export function discoverManifests(root) { | |
| const out = []; | |
| function walk(dir, depth) { | |
| if (depth > 5) return; // guard against very deep nesting | |
| let entries; | |
| try { | |
| entries = fs.readdirSync(dir, { withFileTypes: true }); | |
| } catch { | |
| return; | |
| } | |
| for (const e of entries) { | |
| if (EXCLUDED_SEGMENTS.has(e.name)) continue; | |
| const full = path.join(dir, e.name); | |
| if (e.isDirectory()) { | |
| walk(full, depth + 1); | |
| } else if (e.name === "package.json") { | |
| out.push(path.relative(root, full).replace(/\\/g, "/")); | |
| } | |
| } | |
| } | |
| walk(root, 0); | |
| return out.sort(); | |
| } | |
| /** Nomes de deps no manifesto que não estão na allowlist (de-dup, ordem preservada). */ | |
| export function findUnapprovedDeps(depNames, allowlist) { | |
| const seen = new Set(); | |
| const out = []; | |
| for (const name of depNames) { | |
| if (seen.has(name)) continue; | |
| seen.add(name); | |
| if (!allowlist.has(name)) out.push(name); | |
| } | |
| return out; | |
| } | |
| function depNamesFromManifest(root, rel) { | |
| const full = path.join(root, rel); | |
| if (!fs.existsSync(full)) return []; | |
| let pkg; | |
| try { | |
| pkg = JSON.parse(fs.readFileSync(full, "utf8")); | |
| } catch { | |
| return []; // skip malformed manifests (e.g. reference code) | |
| } | |
| return [ | |
| ...Object.keys(pkg.dependencies || {}), | |
| ...Object.keys(pkg.devDependencies || {}), | |
| ...Object.keys(pkg.optionalDependencies || {}), | |
| ...Object.keys(pkg.peerDependencies || {}), | |
| ]; | |
| } | |
| function collectDepNames(root) { | |
| return discoverManifests(root).flatMap((rel) => depNamesFromManifest(root, rel)); | |
| } | |
| // ─── Task 7.8: registry-existence + age-cooldown ────────────────────────────── | |
| /** | |
| * Pure function — determines whether a package is old enough to be trusted. | |
| * | |
| * A dep that was just registered (within the last 72h) is a red flag for | |
| * slopsquatting: an attacker can register the name an AI hallucinated within | |
| * minutes of the hallucination becoming public. The 72h window gives the npm | |
| * security team time to act and gives maintainers a chance to notice. | |
| * | |
| * @param {number} timeCreatedMs - Unix timestamp (ms) of when the package was | |
| * first published to the registry (npm `time.created` field). | |
| * @param {number} nowMs - Unix timestamp (ms) for "now" (injectable for tests). | |
| * @param {number} minAgeHours - Minimum acceptable age in hours (default 72). | |
| * @returns {{ ok: boolean; ageHours: number }} ok=true if old enough. | |
| */ | |
| export function evaluateDepAge(timeCreatedMs, nowMs, minAgeHours = 72) { | |
| const ageHours = (nowMs - timeCreatedMs) / (1000 * 60 * 60); | |
| return { ok: ageHours >= minAgeHours, ageHours }; | |
| } | |
| /** | |
| * Queries the npm registry for a package. | |
| * Returns { exists: boolean, createdMs: number | null } or null on network error. | |
| * Network failures are treated as "offline" — the caller decides what to do. | |
| * | |
| * @param {string} pkgName | |
| * @param {number} timeoutMs - How long to wait for the registry (default 8 000). | |
| * @returns {{ exists: boolean; createdMs: number | null } | null} | |
| */ | |
| export function queryNpmRegistry(pkgName, timeoutMs = 8000) { | |
| // Scope packages need URL-encoding for the `npm view` command. | |
| // `npm view` accepts scoped packages natively — no encoding needed. | |
| try { | |
| const raw = execFileSync("npm", ["view", pkgName, "time.created", "--json"], { | |
| encoding: "utf8", | |
| timeout: timeoutMs, | |
| // Suppress npm progress/warn output on stderr | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| // npm view --json emits a quoted string or null/empty for missing fields | |
| const trimmed = raw.trim(); | |
| if (!trimmed) { | |
| // Package exists but has no time.created (very unusual; treat as exists, age unknown) | |
| return { exists: true, createdMs: null }; | |
| } | |
| const parsed = JSON.parse(trimmed); | |
| if (!parsed) return { exists: true, createdMs: null }; | |
| const ms = new Date(parsed).getTime(); | |
| return { exists: true, createdMs: Number.isFinite(ms) ? ms : null }; | |
| } catch (err) { | |
| // npm exits with code 1 when the package is NOT found ("E404") | |
| const stderr = err.stderr?.toString() || ""; | |
| const stdout = err.stdout?.toString() || ""; | |
| if ( | |
| stderr.includes("E404") || | |
| stdout.includes("E404") || | |
| stderr.includes("npm ERR! code E404") | |
| ) { | |
| return { exists: false, createdMs: null }; | |
| } | |
| // Any other error (ETIMEDOUT, ENOTFOUND, etc.) = network/offline — return null | |
| return null; | |
| } | |
| } | |
| /** | |
| * For a list of new (unapproved) deps, performs registry existence + age checks. | |
| * Returns an object with three lists: | |
| * - notFound: packages that do NOT exist in the registry (likely hallucinated) | |
| * - tooNew: packages that exist but were published within the last 72h | |
| * - offline: packages we could not verify (registry unreachable) | |
| * | |
| * Designed to run AFTER findUnapprovedDeps — only called when there are new deps. | |
| * | |
| * @param {string[]} newDeps | |
| * @param {number} minAgeHours | |
| * @param {number} nowMs | |
| * @returns {{ notFound: string[]; tooNew: Array<{name:string,ageHours:number}>; offline: string[] }} | |
| */ | |
| export function auditNewDepsRegistry(newDeps, minAgeHours = 72, nowMs = Date.now()) { | |
| const notFound = []; | |
| const tooNew = []; | |
| const offline = []; | |
| for (const dep of newDeps) { | |
| const result = queryNpmRegistry(dep); | |
| if (result === null) { | |
| // Network error — skip gracefully | |
| offline.push(dep); | |
| continue; | |
| } | |
| if (!result.exists) { | |
| notFound.push(dep); | |
| continue; | |
| } | |
| if (result.createdMs !== null) { | |
| const { ok, ageHours } = evaluateDepAge(result.createdMs, nowMs, minAgeHours); | |
| if (!ok) { | |
| tooNew.push({ name: dep, ageHours: Math.round(ageHours * 10) / 10 }); | |
| } | |
| } | |
| // exists + old enough (or age unknown) → pass silently | |
| } | |
| return { notFound, tooNew, offline }; | |
| } | |
| function main() { | |
| if (!fs.existsSync(ALLOWLIST_PATH)) { | |
| console.error( | |
| `[check-deps] FAIL — ${path.basename(ALLOWLIST_PATH)} ausente. Gere com:\n` + | |
| ` node -e "require('./scripts/check/check-deps.mjs')" (ou veja o passo de bootstrap no PLANO)` | |
| ); | |
| process.exit(1); | |
| } | |
| const allowlist = new Set(JSON.parse(fs.readFileSync(ALLOWLIST_PATH, "utf8")).allowed || []); | |
| const allDepNames = collectDepNames(ROOT); | |
| // 6A.8: stale-allowlist enforcement. | |
| // A dep in the allowlist that is no longer used in ANY manifest is stale — the dep | |
| // was removed, but the allowlist entry was not. Stale entries let the dep silently | |
| // re-appear without triggering the review gate (regression risk). | |
| // Note: only flag entries that appear in NO manifest; a dep may be in the allowlist | |
| // but only transitively installed, so we check against what manifests declare. | |
| const liveDepSet = new Set(allDepNames); | |
| assertNoStale(allowlist, liveDepSet, "check-deps"); | |
| const unapproved = findUnapprovedDeps(allDepNames, allowlist); | |
| if (unapproved.length) { | |
| // Task 7.8: For each new dep, run registry-existence + age-cooldown checks. | |
| // This enriches the error message — tells the reviewer whether the package | |
| // even exists and how recently it was published, before they allowlist it. | |
| // Failures here do NOT add extra exit(1) calls — the allowlist gate already | |
| // fails; these are purely informational addenda to the error output. | |
| console.error( | |
| `[check-deps] ${unapproved.length} dependência(s) FORA da allowlist:\n` + | |
| unapproved.map((d) => " ✗ " + d).join("\n") + | |
| `\n → confirme que o pacote é legítimo (existe no registry, publisher conhecido, não é typosquat)\n` + | |
| ` e adicione o nome a dependency-allowlist.json ("allowed"). Esse é o ponto de revisão humana.` | |
| ); | |
| // Registry audit (Task 7.8) — runs only when there are new deps. | |
| // Failures are non-fatal on network errors; registry check is advisory enrichment | |
| // (the allowlist gate above is the hard block). | |
| console.error(`[check-deps] Verificando deps novas no registry npm (Task 7.8)…`); | |
| const { notFound, tooNew, offline } = auditNewDepsRegistry(unapproved); | |
| if (offline.length) { | |
| console.warn( | |
| `[check-deps] WARN — registry npm inacessível (offline?); ` + | |
| `não foi possível verificar: ${offline.join(", ")}` | |
| ); | |
| } | |
| if (notFound.length) { | |
| console.error( | |
| `[check-deps] BLOQUEIO EXTRA — ${notFound.length} dep(s) NÃO encontrada(s) no registry npm ` + | |
| `(provável nome alucinado — NÃO adicionar à allowlist!):\n` + | |
| notFound.map((d) => ` ✗✗ ${d} (não existe no registry)`).join("\n") | |
| ); | |
| } | |
| if (tooNew.length) { | |
| console.error( | |
| `[check-deps] BLOQUEIO EXTRA — ${tooNew.length} dep(s) publicada(s) há <72h ` + | |
| `(age-cooldown anti-slopsquatting — aguarde 72h após publicação):\n` + | |
| tooNew.map((d) => ` ✗✗ ${d.name} (publicada há ~${d.ageHours}h)`).join("\n") | |
| ); | |
| } | |
| process.exit(1); | |
| } | |
| if (process.exitCode === 1) return; // stale entries already logged | |
| const manifests = discoverManifests(ROOT); | |
| console.log( | |
| `[check-deps] OK — ${allowlist.size} dependências na allowlist, ` + | |
| `${manifests.length} manifests escaneados, nenhuma nova dep` | |
| ); | |
| } | |
| if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); | |