Files
2026-07-13 13:39:12 +08:00

290 lines
11 KiB
JavaScript

#!/usr/bin/env node
// 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();