Ctrl K
ComunidadGuías…

plugin-audit.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// @ts-check
// plugin-audit.mjs — static safety scan for COMMUNITY/registry plugins (run by
// `plugins.mjs add` and by the registry-validate CI). Lives at repo ROOT (not
// under plugins/) on purpose: it must reference forbidden-API names + firewall
// phrases, and root files are not walked by test-all's plugin deny-list/firewall
// greps. Bundled plugins in plugins/ are reviewed in-tree and are NOT subject to
// this scan (apify legitimately uses its own pinned client).
//
// This is a STATIC heuristic, not containment — it raises the bar for an honest
// or lazily-malicious author and gives the reviewer a checklist. A determined
// attacker can obfuscate; the real controls are review + pinning + capability.

import { readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { listTree } from './lib/walk-tree.mjs';

// Built from fragments so the literal API/firewall tokens never appear verbatim
// (keeps this file clean against any future repo-wide grep).
const cp = 'child' + '_process';
const FORBIDDEN_MODULES = new Set([
  cp, 'node:' + cp, 'playwright', 'worker_threads', 'node:worker_threads',
  'vm', 'node:vm', 'node:http', 'node:https', 'node:net', 'node:dns',
  'node:dns/promises', 'node:tls', 'node:dgram', 'http', 'https', 'net', 'dns', 'tls', 'dgram',
  'node:cluster', 'node:v8', 'node:inspector', 'node:repl',
]);
// node: builtins a plugin may use (no egress, no spawn).
const ALLOWED_NODE = new Set([
  'node:crypto', 'node:url', 'node:path', 'node:buffer', 'node:util',
  'node:querystring', 'node:string_decoder', 'node:assert', 'node:events',
  'node:fs', 'node:fs/promises', 'node:os', 'node:zlib', 'node:stream',
]);

const IMPORT_RE = /\bimport\s+(?:[\s\S]*?\s+from\s+)?["']([^"']+)["']/g;
const DYN_IMPORT_RE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
const REQUIRE_RE = /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g;
const firewallRe = new RegExp('\\b(' + ['revenue', 'pricing', 'paywall', 'monetiz\\w*', 'moat'].join('|') + ')\\b', 'i');

// --- CLI args ---
const KNOWN_FLAGS = ['--help', '-h'];

const USAGE = `Usage:
  node plugin-audit.mjs <plugin-dir>
  node plugin-audit.mjs --help`;

function collectSpecifiers(src) {
  const specs = [];
  for (const re of [IMPORT_RE, DYN_IMPORT_RE, REQUIRE_RE]) {
    re.lastIndex = 0;
    let m;
    while ((m = re.exec(src)) !== null) specs.push(m[1]);
  }
  return specs;
}

function listMjs(dir) {
  return listTree(dir, {
    match: /\.(mjs|js|cjs)$/,
    skip: (e) => e.name === 'node_modules' || e.name === '.git',
    // Deliberately walks INTO a nested checkout. This is a deny-list scan over
    // third-party plugin code: a directory that could opt out of the audit by
    // planting a `.git` marker is a place to hide a forbidden import.
    allowNestedCheckouts: true,
    // ...and follows links, for the same reason. `import()` resolves a symlink
    // of either kind, so whatever a link reaches is code the plugin can load:
    // a symlinked file (which the hand-rolled walk this replaced did read) and
    // a symlinked directory (which it did not — a hole this closes). walkTree
    // stops at a link onto its own ancestor, so a loop cannot hang the audit.
    links: 'follow',
  }).map((abs) => ({ abs, rel: path.relative(dir, abs).split(path.sep).join('/') }));
}

/**
 * @param {string} dir absolute plugin directory
 * @returns {{ ok: boolean, findings: Array<{file:string, issue:string}> }}
 */
export function auditPlugin(dir) {
  const findings = [];
  for (const { abs, rel } of listMjs(dir)) {
    const src = readFileSync(abs, 'utf8');
    for (const spec of collectSpecifiers(src)) {
      const isRelative = spec.startsWith('./') || spec.startsWith('../');
      const isNode = spec.startsWith('node:') || ['crypto', 'url', 'path', 'buffer', 'util', 'fs', 'os', 'zlib', 'stream', 'events', 'assert', 'querystring'].includes(spec);
      if (FORBIDDEN_MODULES.has(spec)) findings.push({ file: rel, issue: `forbidden import "${spec}" — community plugins egress only through ctx.fetch and may not spawn/raw-socket` });
      else if (isNode && !ALLOWED_NODE.has(spec) && !ALLOWED_NODE.has('node:' + spec)) findings.push({ file: rel, issue: `node builtin "${spec}" is not on the plugin allowlist` });
      else if (!isRelative && !isNode) findings.push({ file: rel, issue: `bare-specifier import "${spec}" — registry plugins must be dependency-free (relative + allowlisted node: builtins only)` });
    }
    // Direct egress / code-exec primitives (ctx.fetch / x.fetch are fine).
    if (/(?<![.\w$])fetch\s*\(/.test(src)) findings.push({ file: rel, issue: 'direct global fetch() — use ctx.fetch so the egress allowlist applies' });
    if (/\bXMLHttpRequest\b|\bWebSocket\b/.test(src)) findings.push({ file: rel, issue: 'XMLHttpRequest/WebSocket egress — use ctx.fetch' });
    if (/\beval\s*\(|new\s+Function\s*\(/.test(src)) findings.push({ file: rel, issue: 'eval/new Function — dynamic code execution is not allowed' });
    if (/process\s*\.\s*(binding|dlopen)\b/.test(src)) findings.push({ file: rel, issue: 'process.binding/dlopen — native escape hatch not allowed' });
    if (firewallRe.test(src)) findings.push({ file: rel, issue: 'commercial/monetization wording in a shipped community plugin (keep it mission-framed)' });
  }
  return { ok: findings.length === 0, findings };
}

// CLI: node plugin-audit.mjs <dir>
if (isMainModule(import.meta.url)) {
  const args = process.argv.slice(2);
  validateFlags(args, KNOWN_FLAGS, USAGE);
  const dir = args.find(a => !a.startsWith('-'));
  if (!dir) { console.error(USAGE); process.exit(2); }
  let result;
  try { result = auditPlugin(path.resolve(dir)); } catch (e) { console.error('audit failed:', e.message); process.exit(2); }
  if (result.ok) { console.log('✓ audit clean'); process.exit(0); }
  for (const f of result.findings) console.log(`✗ ${f.file}: ${f.issue}`);
  process.exit(1);
}