UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

275 lines (274 loc) • 11.5 kB
/** * Spawning cf (spec §F3, §F6). * * cf always runs as `process.execPath <cf entry> …argv` with no shell (and so * no `.cmd` shim on Windows), from a private, empty 0700 working directory, so * a project's `.env`, `.env.local`, `cloudflare.config.ts`, directory profile * bindings and account cache never apply. Output is capped, stderr is never * echoed, and every failure is reduced to a small classification. */ import { spawn } from 'node:child_process'; import { chmod, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import { CF_TOKEN_ENV } from './child-env.js'; export const CF_CALL_TIMEOUT_MS = 45_000; export const STDOUT_CAP_BYTES = 8 * 1024 * 1024; /** `rulesets get` bodies can be large managed rulesets. */ export const RULESET_STDOUT_CAP_BYTES = 16 * 1024 * 1024; export const STDERR_CAP_BYTES = 64 * 1024; const KILL_GRACE_MS = 2_000; /** * Flags that skip confirmations, switch cf to local simulation or project * config, or send a request body. No allowlisted argv contains them; the * runner refuses to spawn one that does. */ export const FORBIDDEN_CF_FLAGS = Object.freeze(['--force', '-f', '--yes', '-y', '--local', '--mode', '-m', '--body', '--persist-to']); export function assertSafeCfArgv(argv) { for (const arg of argv) { const flag = arg.split('=')[0]; if (FORBIDDEN_CF_FLAGS.includes(flag)) throw new Error(`Refusing to run cf with ${flag}.`); } } /** Owns the private working directory and every live cf child. */ export class CfProcessRunner { entry; privateDir; active = new Set(); disposed = false; /** Set once the runner kills its children (abort or shutdown), so those kills are not read as timeouts. */ terminating = false; constructor(entry, privateDir) { this.entry = entry; this.privateDir = privateDir; } static async create(entry) { const privateDir = await mkdtemp(join(tmpdir(), 'vigilbase-cf-')); await chmod(privateDir, 0o700); return new CfProcessRunner(entry, privateDir); } run(argv, options) { assertSafeCfArgv(argv); if (this.disposed) return Promise.resolve(failedToStart()); const timeoutMs = options.timeoutMs ?? CF_CALL_TIMEOUT_MS; const stdoutCap = options.stdoutCapBytes ?? STDOUT_CAP_BYTES; return new Promise((resolve) => { let child; try { child = spawn(process.execPath, [this.entry, ...argv], { shell: false, cwd: this.privateDir, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: timeoutMs, killSignal: 'SIGKILL', }); } catch { resolve(failedToStart()); return; } this.active.add(child); const stdout = []; const stderr = []; let stdoutBytes = 0; let stderrBytes = 0; let timedOut = false; let stdoutTooLarge = false; let settled = false; // Node's own `timeout` option kills the child too; this timer only makes // the cause observable when it wins the race. const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs); timer.unref?.(); child.stdout?.on('data', (chunk) => { if (stdoutTooLarge) return; stdoutBytes += chunk.byteLength; if (stdoutBytes > stdoutCap) { stdoutTooLarge = true; stdout.length = 0; child.kill('SIGKILL'); return; } stdout.push(chunk); }); child.stderr?.on('data', (chunk) => { if (stderrBytes >= STDERR_CAP_BYTES) return; const room = STDERR_CAP_BYTES - stderrBytes; stderr.push(chunk.byteLength > room ? chunk.subarray(0, room) : chunk); stderrBytes += Math.min(chunk.byteLength, room); }); const finish = (exitCode, signal, spawnFailed) => { if (settled) return; settled = true; clearTimeout(timer); this.active.delete(child); // A child killed by a signal we did not send for the stdout cap or a // shutdown was killed by the spawn timeout. if (signal !== null && !stdoutTooLarge && !this.terminating) timedOut = true; resolve({ exitCode, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), timedOut, stdoutTooLarge, spawnFailed, }); }; child.once('error', () => finish(null, null, true)); child.once('close', (code, signal) => finish(typeof code === 'number' ? code : null, signal ?? null, false)); }); } /** * The only interactive cf passthroughs: `cf auth create` (sign-in) and * `cf auth delete` (logout). With `stdoutToStderr` (`--json`), cf's stdout * is sent to stderr so the CLI's stdout stays a single JSON object. */ runInteractive(argv, env, options = {}) { assertSafeCfArgv(argv); return new Promise((resolve, reject) => { const child = spawn(process.execPath, [this.entry, ...argv], { shell: false, cwd: this.privateDir, env, stdio: ['inherit', options.stdoutToStderr ? 2 : 'inherit', 'inherit'], windowsHide: true, }); this.active.add(child); child.once('error', (error) => { this.active.delete(child); reject(error); }); child.once('close', (code) => { this.active.delete(child); resolve(typeof code === 'number' ? code : 1); }); }); } get activeCount() { return this.active.size; } /** SIGTERM every live child, then SIGKILL whatever is still running after 2 s. */ async terminateAll() { this.terminating = true; if (this.active.size === 0) return; const children = [...this.active]; for (const child of children) child.kill('SIGTERM'); await new Promise((resolve) => { const timer = setTimeout(() => { for (const child of children) if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); resolve(); }, KILL_GRACE_MS); timer.unref?.(); Promise.all(children.map((child) => child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise((done) => child.once('close', () => done())))).then(() => { clearTimeout(timer); resolve(); }, () => undefined); }); } async dispose() { if (this.disposed) return; this.disposed = true; await this.terminateAll(); await rm(this.privateDir, { recursive: true, force: true }); } } function failedToStart() { return { exitCode: null, stdout: '', stderr: '', timedOut: false, stdoutTooLarge: false, spawnFailed: true }; } /** * Run `work` with a private cf runner; the directory is removed afterwards and * on SIGINT, when live children get SIGTERM and then SIGKILL. */ export async function withCfRunner(entry, work) { const runner = await CfProcessRunner.create(entry); const onSignal = () => { void runner.dispose().finally(() => process.exit(130)); }; process.once('SIGINT', onSignal); try { return await work(runner); } finally { process.removeListener('SIGINT', onSignal); await runner.dispose(); } } const BOX_DRAWING = /[\u2500-\u257f]/g; // cf's API error header: `${status} ${statusText}`, where statusText is empty // outside 400-504, optionally followed by ` · HTTP <path>` (absent when the // response URL is unknown). Body lines (`[code] message`, notes) never match: // after the status only a known status text, whitespace or the `·` suffix may follow. const STATUS_TEXT = '(?:Bad Request|Unauthorized|Forbidden|Not Found|Method Not Allowed|Conflict|Unprocessable Entity|' + 'Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)'; const STATUS_LINE = new RegExp(`^\\s*([1-5]\\d\\d)(?: ${STATUS_TEXT})?\\s*(?:\u00b7.*)?$`, 'gm'); // Node/undici network failures and the cf SDK's own client timeout ("Timeout exceeded when calling …"). const NETWORK = /ENOTFOUND|ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|UND_ERR|fetch failed|socket hang up|Timeout exceeded|Request timed out/i; /** ANSI and box-drawing characters removed; lines kept. */ export function cleanCfStderr(stderr) { return stripVTControlCharacters(stderr).replace(BOX_DRAWING, ' ').replace(/\r\n?/g, '\n'); } // cf's non-interactive no-credential / expired-token message names the token variable. const AUTH = new RegExp(`not logged in|cf auth (login|create)|expired|does not exist|necessary to set a ${CF_TOKEN_ENV}`, 'i'); /** The last status header line in cleaned stderr, if any. */ function statusFromLines(lines) { let status = null; for (const match of lines.matchAll(STATUS_LINE)) status = Number(match[1]); return status; } /** * Classify a failed cf call from its stderr, in order: Node version, unknown * command, auth without an HTTP status, the HTTP status line * (`${status} ${text}[ · HTTP <path>]`), network, anything else. cf wraps * messages at the terminal width, so word patterns run over collapsed text. */ export function classifyCfStderr(stderr) { const lines = cleanCfStderr(stderr); const flat = lines.replace(/\s+/g, ' '); if (/cf requires Node\.js/.test(flat)) return { kind: 'node_unsupported' }; if (/Did you mean|Unknown (argument|command)/.test(flat)) return { kind: 'command_unavailable' }; const status = statusFromLines(lines); if (status === null && AUTH.test(flat)) return { kind: 'auth' }; if (status !== null) return { kind: 'http', status }; if (NETWORK.test(flat)) return { kind: 'network' }; return { kind: 'cf_error' }; } /** * Parse cf stdout: lists print a bare JSON array, gets a bare object. cf prints * nothing for a `null` result, so an empty get is `null` (the projectors decide). */ export function parseCfJson(stdout, shape) { if (shape === 'object' && stdout.trim() === '') return { ok: true, value: null }; let value; try { value = JSON.parse(stdout); } catch { return { ok: false }; } if (shape === 'array') return Array.isArray(value) ? { ok: true, value } : { ok: false }; if (value === null || (typeof value === 'object' && !Array.isArray(value))) return { ok: true, value }; return { ok: false }; }