Files
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

98 lines
5.2 KiB
JavaScript

#!/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 { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
// 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');
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) {
const out = [];
const walk = (d, rel) => {
for (const e of readdirSync(d, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name === '.git') continue;
const abs = path.join(d, e.name);
const r = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) walk(abs, r);
else if (/\.(mjs|js|cjs)$/.test(e.name)) out.push({ abs, rel: r });
}
};
walk(dir, '');
return out;
}
/**
* @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 (import.meta.url === (await import('node:url')).pathToFileURL(process.argv[1] || '').href) {
const dir = process.argv[2];
if (!dir) { console.error('Usage: node plugin-audit.mjs <plugin-dir>'); 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);
}