@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
275 lines (274 loc) • 11.5 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 { 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 };
}