UNPKG

@vigilbase/cli

Version:

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

309 lines (308 loc) • 14.8 kB
/** * The Vigilbase-managed install of Cloudflare's cf (spec "one command", decision 5). * * When no usable cf is found, the full flow may install the pinned release * into `<configDir>/cf/<version>/` with the npm that ships with this Node: * * - npm runs as `process.execPath <npm-cli.js> install --prefix <staging> …` * with no shell, from the fresh 0700 staging directory, with the user's * environment (npm needs their npmrc, proxy and CA settings) minus every * `CF_*` / `CLOUDFLARE_*` variable and, defensively, any variable whose * value holds a session code, a verification token or `--email`. (npx 10 * passes arguments only in argv, not in the environment: * `npm_lifecycle_script` is just the bin name.) * - `--ignore-scripts`: cf runs `--version`, `--dry-run` and API reads without * lifecycle scripts. `--omit=optional`: the optional dependencies are the * workerd and sharp platform binaries (about 150 MB). In cf 1.0.0-beta.13 * they are loaded only by `--local` mode and the `workers`, `deploy` and * `previews` command trees (static and dynamic import graph of * `dist/*.mjs`), none of which the checkup runs; see LOCAL.md. * - Integrity is pinned: after npm exits, the staging lockfile must record * `node_modules/cf` at exactly the pinned version and npm `dist.integrity`, * and the installed package.json must agree; otherwise the staging * directory is deleted (CF_INSTALL_FAILED). Only then is it renamed into * place, so a half-finished install is never used. `locateCf` still applies * its identity checks to the result. * - Ctrl-C while npm runs kills npm and removes the staging directory. Staging * directories left by a crash or a closed terminal are removed by the next * install once they are older than the npm timeout (a concurrent install is * always younger), and by `logout`. */ import { spawn as nodeSpawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { LocalCheckupError } from '../errors.js'; /** The cf release the CLI installs; `integrity` is npm's `dist.integrity` for it. */ export const MANAGED_CF = Object.freeze({ version: '1.0.0-beta.13', integrity: 'sha512-dCjpzCJvjudIu8IQbXCJfy9diFoyHU84D/30K4lJp7jUaBgvjJypsMFzLqvUFOuwhF1ggR/igsLqXyehAOR2FQ==', /** Measured `du` of the install without optional dependencies (darwin-arm64: 73 MB). */ approxSize: 'about 75 MB', }); export const NPM_INSTALL_TIMEOUT_MS = 10 * 60_000; /** A staging directory older than this is a leftover, never a running install. */ const STALE_STAGING_MS = NPM_INSTALL_TIMEOUT_MS + 60_000; const STAGING_PREFIX = '.staging-'; const NPM_STDERR_KEEP_BYTES = 64 * 1024; const MAX_LOCKFILE_BYTES = 16 * 1024 * 1024; export const NPM_STDERR_TAIL_LINES = 20; /** A Vigilbase session code or verification token, or a command line naming `--email` (were a launcher to export one). */ const VIGILBASE_SECRET_MARKER = /vbc[sv]1_|--email\b/; /** `<configDir>/cf`: every managed version, plus any leftover staging directory. */ export function managedCfRoot(configDir) { return path.join(configDir, 'cf'); } export function managedCfInstallDir(configDir, version = MANAGED_CF.version) { return path.join(managedCfRoot(configDir), version); } export function managedCfPackageDir(installDir) { return path.join(installDir, 'node_modules', 'cf'); } /** The npm arguments after `process.execPath`. */ export function npmInstallArgv(npmCli, prefix, version = MANAGED_CF.version) { return [npmCli, 'install', '--prefix', prefix, '--no-audit', '--no-fund', '--ignore-scripts', '--omit=optional', `cf@${version}`]; } /** * The npm to run: `npm_execpath` when it names an existing `npm-cli.js` (or * `npx-cli.js`, whose sibling is used), otherwise the npm bundled beside this * Node. `null` when neither exists. */ export function resolveNpmCli(options = { env: process.env }) { const platform = options.platform ?? process.platform; const p = platform === 'win32' ? path.win32 : path.posix; const exists = options.exists ?? existsSync; const fromEnv = options.env.npm_execpath; if (fromEnv && p.isAbsolute(fromEnv)) { const name = p.basename(fromEnv); const candidate = name === 'npm-cli.js' ? fromEnv : name === 'npx-cli.js' ? p.join(p.dirname(fromEnv), 'npm-cli.js') : null; if (candidate && exists(candidate)) return candidate; } const nodeDir = p.dirname(options.execPath ?? process.execPath); const bundled = platform === 'win32' ? p.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js') : p.resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'); return exists(bundled) ? bundled : null; } /** The user's environment minus Cloudflare variables and anything carrying a session code, token or `--email`. */ export function npmChildEnv(parent) { const env = {}; for (const [name, value] of Object.entries(parent)) { if (typeof value !== 'string') continue; if (/^(?:CF_|CLOUDFLARE_)/i.test(name)) continue; if (VIGILBASE_SECRET_MARKER.test(value)) continue; env[name] = value; } env.npm_config_update_notifier = 'false'; return env; } async function readJsonCapped(file) { const info = await stat(file).catch(() => null); if (!info || !info.isFile() || info.size > MAX_LOCKFILE_BYTES) return undefined; try { return JSON.parse(await readFile(file, 'utf8')); } catch { return undefined; } } /** * True when every lockfile npm wrote records `node_modules/cf` at the pinned * version and integrity (at least one must exist), and the installed * package.json has that version. */ export async function verifyManagedInstall(installDir, pin = MANAGED_CF) { let lockfiles = 0; for (const file of [path.join(installDir, 'package-lock.json'), path.join(installDir, 'node_modules', '.package-lock.json')]) { if (!(await stat(file).catch(() => null))) continue; lockfiles++; const lock = await readJsonCapped(file); const entry = lock?.packages?.['node_modules/cf']; if (!entry || entry.version !== pin.version || entry.integrity !== pin.integrity) return false; } if (lockfiles === 0) return false; const manifest = await readJsonCapped(path.join(managedCfPackageDir(installDir), 'package.json')); return manifest?.version === pin.version; } /** The managed cf package directory when a verified install exists, else null. */ export async function findManagedCf(configDir) { const installDir = managedCfInstallDir(configDir); const info = await lstat(installDir).catch(() => null); if (!info || !info.isDirectory() || info.isSymbolicLink()) return null; return (await verifyManagedInstall(installDir)) ? managedCfPackageDir(installDir) : null; } /** Create `dir` 0700 if missing; refuse a symlink or non-directory in its place. */ export async function ensurePrivateDir(dir) { await mkdir(path.dirname(dir), { recursive: true, mode: 0o700 }); await mkdir(dir, { mode: 0o700 }).catch((error) => { if (error.code !== 'EEXIST') throw error; }); const info = await lstat(dir); if (!info.isDirectory() || info.isSymbolicLink()) { throw new Error(`${dir} must be a directory, not a symbolic link or file.`); } if (process.platform !== 'win32' && (info.mode & 0o777) !== 0o700) await chmod(dir, 0o700); } /** A CF_INSTALL_FAILED error that carries the redacted tail of npm's stderr. */ export class CfInstallError extends LocalCheckupError { npmOutput; constructor(message, npmOutput = []) { super('CF_INSTALL_FAILED', message); this.npmOutput = npmOutput; } } const SECRET_LINE = /_auth|_password|authorization|bearer|npm_[A-Za-z0-9]{20,}|vbc[sv]1_|--email\b/i; /** The last lines of npm's stderr, dropping any line that could carry a registry credential. */ export function npmStderrTail(stderr, lines = NPM_STDERR_TAIL_LINES) { return stderr.replace(/\r\n?/g, '\n').split('\n') .map((line) => line.trimEnd()) .filter((line) => line.trim() !== '' && !SECRET_LINE.test(line)) .slice(-lines); } function runNpm(spawn, args, options) { return new Promise((resolve) => { let child; try { child = spawn(process.execPath, args, { shell: false, cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); } catch { resolve({ exitCode: null, stderr: '', timedOut: false, spawnFailed: true }); return; } options.onSpawn(child); let stderr = ''; let timedOut = false; let settled = false; const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, options.timeoutMs); timer.unref?.(); child.stdout?.on('data', () => undefined); child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); if (stderr.length > NPM_STDERR_KEEP_BYTES) stderr = stderr.slice(-NPM_STDERR_KEEP_BYTES); }); const finish = (exitCode, spawnFailed) => { if (settled) return; settled = true; clearTimeout(timer); resolve({ exitCode, stderr, timedOut, spawnFailed }); }; child.once('error', () => finish(null, true)); child.once('close', (code) => finish(typeof code === 'number' ? code : null, false)); }); } /** Remove `.staging-*` leftovers older than `maxAgeMs`; a younger one may belong to a concurrent install. */ export async function removeStaleStaging(root, maxAgeMs = STALE_STAGING_MS, now = Date.now()) { const names = await readdir(root).catch(() => []); const removed = []; for (const name of names.filter((candidate) => candidate.startsWith(STAGING_PREFIX)).sort()) { const target = path.join(root, name); const info = await lstat(target).catch(() => null); if (!info || now - info.mtimeMs <= maxAgeMs) continue; await rm(target, { recursive: true, force: true }).then(() => removed.push(name), () => undefined); } return removed; } /** * Ctrl-C while npm runs: kill npm, wait briefly for it to exit, remove the * staging directory, then exit 130 (as `withCfRunner` does for cf). */ function onInterrupt(staging, running, before) { return () => { before?.(); const child = running(); const closed = child && child.exitCode === null && child.signalCode === null ? new Promise((resolve) => { child.once('close', () => resolve()); setTimeout(resolve, 2_000).unref?.(); }) : Promise.resolve(); child?.kill('SIGKILL'); void closed .then(() => rm(staging, { recursive: true, force: true, maxRetries: 3 })) .catch(() => undefined) .finally(() => process.exit(130)); }; } /** * Install the pinned cf into `<configDir>/cf/<version>/` and return its * package directory. Throws CF_INSTALL_FAILED (a `CfInstallError`) when npm * is missing or fails, or the result does not match the pin; the staging * directory is always removed (on Ctrl-C too). */ export async function installManagedCf(options) { const npmCli = options.npmCli !== undefined ? options.npmCli : resolveNpmCli({ env: options.env }); if (!npmCli) { throw new CfInstallError('npm was not found beside this Node.js, so cf cannot be installed automatically.'); } const root = managedCfRoot(options.configDir); await ensurePrivateDir(root); await removeStaleStaging(root); // realpath: npm keys its lockfile relative to the real prefix, so a symlinked path would not match `node_modules/cf`. const staging = await realpath(await mkdtemp(path.join(root, `${STAGING_PREFIX}${MANAGED_CF.version}-`))); let npmChild = null; const interrupted = onInterrupt(staging, () => npmChild, options.onInterrupt); process.once('SIGINT', interrupted); try { if (process.platform !== 'win32') await chmod(staging, 0o700); const result = await runNpm(options.spawn ?? nodeSpawn, npmInstallArgv(npmCli, staging), { cwd: staging, env: npmChildEnv(options.env), timeoutMs: options.timeoutMs ?? NPM_INSTALL_TIMEOUT_MS, onSpawn: (child) => { npmChild = child; }, }); if (result.spawnFailed) throw new CfInstallError('npm could not be started, so cf was not installed.'); if (result.timedOut) throw new CfInstallError(`npm did not finish installing cf within ${Math.round((options.timeoutMs ?? NPM_INSTALL_TIMEOUT_MS) / 60_000)} minutes.`, npmStderrTail(result.stderr)); if (result.exitCode !== 0) throw new CfInstallError(`npm could not install cf@${MANAGED_CF.version} (exit ${result.exitCode ?? 'signal'}).`, npmStderrTail(result.stderr)); if (!(await verifyManagedInstall(staging))) { throw new CfInstallError(`The installed cf does not match the pinned cf@${MANAGED_CF.version} integrity; it was deleted.`); } const installDir = managedCfInstallDir(options.configDir); // A leftover install that failed verification is replaced; a verified one (a concurrent run) is kept unless it is being replaced. if (!options.replace && await findManagedCf(options.configDir)) return managedCfPackageDir(installDir); await rm(installDir, { recursive: true, force: true }); try { await rename(staging, installDir); } catch { if (await findManagedCf(options.configDir)) return managedCfPackageDir(installDir); throw new CfInstallError('The installed cf could not be moved into place.'); } return managedCfPackageDir(installDir); } finally { process.removeListener('SIGINT', interrupted); await rm(staging, { recursive: true, force: true }).catch(() => undefined); } } /** `logout`: remove every managed cf version (and any staging leftovers). */ export async function removeManagedCf(configDir) { const root = managedCfRoot(configDir); const info = await lstat(root).catch(() => null); if (!info) return { path: root, removed: false }; await rm(root, { recursive: true, force: true }); return { path: root, removed: true }; }