@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
333 lines (332 loc) • 14.1 kB
JavaScript
/**
* 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 { CANCEL_SIGNALS, CheckupCancelledError } from '../errors.js';
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;
/** After a cancel, how long withCfRunner waits for `work` to finish (or undo) what it was doing. */
export const WORK_SETTLE_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;
disposing = null;
/** Set by `cancel`: every pending and later call rejects with it. */
cancelledBy = null;
/** 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.cancelledBy)
return Promise.reject(this.cancelledBy);
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, reject) => {
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);
if (this.cancelledBy) {
reject(this.cancelledBy);
return;
}
// 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);
if (this.cancelledBy)
return Promise.reject(this.cancelledBy);
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);
if (this.cancelledBy)
reject(this.cancelledBy);
else
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);
});
}
/** Stop every child and remove the private directory; every caller waits for the same cleanup. */
dispose() {
this.disposing ??= (async () => {
this.disposed = true;
await this.terminateAll();
await rm(this.privateDir, { recursive: true, force: true });
})();
return this.disposing;
}
/** The run was interrupted by `signal`: calls reject with CANCELLED from now on, then everything is disposed. */
cancel(signal) {
this.cancelledBy ??= new CheckupCancelledError(signal);
return this.dispose();
}
get cancellation() {
return this.cancelledBy;
}
}
function failedToStart() {
return { exitCode: null, stdout: '', stderr: '', timedOut: false, stdoutTooLarge: false, spawnFailed: true };
}
/** Wait for `promise` to settle, at most `ms`. */
async function settleWithin(promise, ms) {
let timer;
await Promise.race([
promise.then(() => undefined, () => undefined),
new Promise((resolve) => { timer = setTimeout(resolve, ms); }),
]);
clearTimeout(timer);
}
/**
* Run `work` with a private cf runner; the directory is removed afterwards.
* On SIGINT, SIGTERM or SIGHUP live children get SIGTERM (then SIGKILL), and
* this rejects with CANCELLED (`CheckupCancelledError`) once the directory is
* gone and `work` has settled (every cf call rejects by then; whatever else it
* waits on gets WORK_SETTLE_MS), so no result of a cancelled run is printed and
* nothing `work` writes lands after CANCELLED. `work` checks `runner.cancellation`
* before (and after) writing anything.
*/
export async function withCfRunner(entry, work, signals = process) {
const runner = await CfProcessRunner.create(entry);
let interrupt = () => undefined;
const interrupted = new Promise((_resolve, reject) => { interrupt = reject; });
const listeners = CANCEL_SIGNALS.map((signal) => {
const listener = () => {
runner.cancel(signal).catch(() => undefined);
interrupt(runner.cancellation);
};
signals.on(signal, listener);
return { signal, listener };
});
let working;
try {
working = work(runner);
const result = await Promise.race([working, interrupted]);
if (runner.cancellation)
throw runner.cancellation;
return result;
}
catch (error) {
if (runner.cancellation && working)
await settleWithin(working, WORK_SETTLE_MS);
throw runner.cancellation ?? error;
}
finally {
for (const { signal, listener } of listeners)
signals.removeListener(signal, listener);
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 };
}