File size: 11,708 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
#!/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();