chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user