UNPKG

@vigilbase/cli

Version:

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

347 lines (341 loc) • 19.8 kB
import { z } from 'zod'; import { CloudflareClient, accountResourceSchema, cloudflareScopeSchema, workspaceSectionSchema, } from '../lib/cloudflare-client.js'; import { assertCommandFlags, asArray, asRecord, ok, outputJson, requireOneClient, withSpinner, } from './helpers.js'; const usage = 'Usage: vigilbase cf [zones|zone <id>|accounts|account <id>|workspace [section]|dns <zone-id>|waf <zone-id>|speed-settings <zone-id>|audit-logs <account-id>|zero-trust <account-id>|developer-platform <account-id>|magic-transit <account-id>|analytics|checkup] [--customer-id …] [--account-id …] [--json]'; const checkupUsage = 'Usage: vigilbase cf checkup [status|start|progress <run-id>|result <run-id>] [--customer-id …] [--account-id …] [--json]'; export const CLOUDFLARE_HELP = `Usage: vigilbase cf <command> [options] Alias: vigilbase cloudflare Inventory and workspace: zones List zones (default); --customer-id, --account-id, --zone-id zone <zone-id> Zone detail; --customer-id selects live details, optional --account-id accounts List accessible accounts; --customer-id, --account-id account <account-id> Stored account detail; --customer-id workspace [section] Workspace bootstrap or section; --customer-id, --account-id, --zone-id Sections: overview, reports, zones, rule-analyzer, accounts, checkups, settings Zone reads (--customer-id <uuid> required; IDs can use --zone-id): dns <zone-id> DNS records, TTL and proxy state waf <zone-id> WAF rules, expressions, analytics and suggestions speed-settings <zone-id> Performance settings (internal users only) Account reads (optional --customer-id; IDs can use --account-id): audit-logs <account-id> Latest provider audit events (up to 100; service-read permission) zero-trust <account-id> Users, Access applications, tunnels and authentication analytics developer-platform <account-id> Workers, Workers analytics and Pages magic-transit <account-id> Prefixes, routes and traffic analytics Zero Trust, Developer Platform and Magic Transit require deployment-read permission. Analytics and checkups: analytics --customer-id <uuid> [--account-id <id> | --zone-id <id>] [--days 1..30] Default 7 days; live zone analytics uses the most recent 23 hours. checkup [status] Latest completed checkup; --customer-id, --account-id checkup start Start and persist a scan; --customer-id, --account-id checkup progress <run-uuid> Read saved progress once checkup result <run-uuid> Read the stored Cloudflare checkup result checkups aliases checkup; list/show alias status; run aliases start. Common options: --json, --one-url <origin>, --timeout <milliseconds>, --help Customer IDs are UUIDs; account and zone IDs are 32 hexadecimal characters. One login, tenant bindings and permissions apply to every command. Checkup start creates a scan run. These commands do not edit provider configuration. See CLOUDFLARE.md for full grammar, API mapping, data limits and examples.`; function validate(schema, input) { const result = schema.safeParse(input); if (!result.success) throw new Error(result.error.issues.map((issue) => issue.message).join(' ')); return result.data; } function dataOf(payload) { return asRecord(payload.data) ?? payload; } function writeLine(ctx, value) { // DNS contents, rule expressions, and evidence can contain terminal controls. ctx.writer.stdout(value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')); } function fields(ctx, data) { for (const [key, value] of Object.entries(data)) { if (value === undefined || value === null) continue; writeLine(ctx, `${key.padEnd(20)} ${typeof value === 'object' ? JSON.stringify(value) : String(value)}`); } } function zones(ctx, values) { const items = asArray(values).map(asRecord).filter((item) => item !== null); if (!items.length) writeLine(ctx, 'No Cloudflare zones.'); for (const zone of items) { const plan = zone.planName ?? zone.plan_name ?? asRecord(zone.plan)?.name; writeLine(ctx, `${String(zone.id ?? zone.zone_id)} ${String(zone.name ?? zone.zone_name).padEnd(28)} ${String(zone.status ?? '-')}${plan ? ` ${String(plan)}` : ''}`); } } export const handleCloudflare = async (ctx) => { assertCommandFlags(ctx, ['customer-id', 'account-id', 'zone-id', 'days']); const subcommand = (ctx.rest[0] ?? 'zones').toLowerCase(); const scope = validate(cloudflareScopeSchema, { customerId: ctx.values['customer-id'], accountId: ctx.values['account-id'], zoneId: ctx.values['zone-id'], }); if (ctx.values.days !== undefined && subcommand !== 'analytics') throw new Error('--days is supported only by cf analytics.'); if (scope.accountId && ['dns', 'waf', 'speed-settings'].includes(subcommand)) throw new Error(`cf ${subcommand} accepts a zone ID and --customer-id; --account-id is not supported.`); if (scope.zoneId && ['accounts', 'account', ...accountResourceSchema.options, 'checkup', 'checkups'].includes(subcommand)) throw new Error(`cf ${subcommand} does not support --zone-id.`); const args = ctx.rest.slice(1); const noExtraArgs = (count, message = usage) => { if (args.length > count) throw new Error(message); }; const emit = (payload, render) => { if (payload.success === false) throw new Error('Cloudflare request failed.'); if (ctx.mode === 'json') outputJson(ctx, payload); else render(dataOf(payload)); return ok(); }; // Validate commands before requesting credentials or making API calls. if (!['zones', 'zone', 'accounts', 'workspace', 'dns', 'waf', 'speed-settings', 'account', ...accountResourceSchema.options, 'analytics', 'checkup', 'checkups'].includes(subcommand)) { throw new Error(usage); } let zoneId; let accountId; const accountResource = accountResourceSchema.safeParse(subcommand); if (accountResource.success || subcommand === 'account') { noExtraArgs(1); if (args[0] && scope.accountId && args[0] !== scope.accountId) throw new Error('The positional account ID and --account-id must match.'); accountId = args[0] ?? scope.accountId; if (!accountId) throw new Error(`Missing <account-id>. Usage: vigilbase cf ${subcommand} <account-id> [--customer-id <uuid>] [--json]`); validate(cloudflareScopeSchema, { accountId }); } else if (['dns', 'waf', 'zone', 'speed-settings'].includes(subcommand)) { noExtraArgs(1); if (args[0] && scope.zoneId && args[0] !== scope.zoneId) throw new Error('The positional zone ID and --zone-id must match.'); zoneId = args[0] ?? scope.zoneId; // Preserve the historical `cf zone` alias for the zone inventory. if (subcommand !== 'zone' && !zoneId) throw new Error(`Missing <zone-id>. Usage: vigilbase cf ${subcommand} <zone-id> --customer-id <uuid> [--json]`); if (zoneId) validate(cloudflareScopeSchema, { zoneId }); // One answers 400 to every caller that leaves the customer out of a zone read. if (subcommand !== 'zone' && !scope.customerId) throw new Error(`cf ${subcommand} requires --customer-id <uuid>. Find it with \`vigilbase cf accounts --json\`.`); } else if (!['workspace', 'checkup', 'checkups'].includes(subcommand)) { noExtraArgs(0); } const section = subcommand === 'workspace' ? (noExtraArgs(1), args[0] ? validate(workspaceSectionSchema, args[0]) : undefined) : undefined; let days = 7; if (subcommand === 'analytics') { if (!scope.customerId) throw new Error('Cloudflare analytics requires --customer-id <uuid>. Find it with `vigilbase cf accounts --json`.'); if (scope.zoneId && scope.accountId) throw new Error('Choose either --zone-id or --account-id for analytics.'); days = validate(z.coerce.number().int().min(1).max(30), ctx.values.days ?? 7); } const action = (args[0] ?? 'status').toLowerCase(); let runId; if (subcommand === 'checkup' || subcommand === 'checkups') { if (['progress', 'result'].includes(action)) { noExtraArgs(2, checkupUsage); if (args[1] === undefined) throw new Error(`Missing <run-uuid>. Usage: vigilbase cf checkup ${action} <run-uuid> [--json]`); runId = validate(z.string().uuid('A checkup run UUID is required.'), args[1]); if (scope.customerId || scope.accountId) throw new Error(`cf checkup ${action} is scoped by run ID and does not accept --customer-id or --account-id.`); } else if (['status', 'list', 'show', 'start', 'run'].includes(action)) { noExtraArgs(1, checkupUsage); } else throw new Error(checkupUsage); } const one = await requireOneClient(ctx); const cloudflare = new CloudflareClient(one); if (subcommand === 'speed-settings') { const payload = await withSpinner(ctx, 'Loading Cloudflare speed settings', () => cloudflare.speedSettings(zoneId, scope.customerId)); return emit(payload, (data) => { if (!Object.keys(data).length) writeLine(ctx, 'No speed settings returned; provider availability or permissions may limit this view.'); else fields(ctx, data); }); } if (accountResource.success) { const payload = await withSpinner(ctx, `Loading Cloudflare ${subcommand}`, () => cloudflare.accountResource(accountResource.data, accountId, scope.customerId)); return emit(payload, (data) => { if (subcommand === 'audit-logs') { const logs = asArray(data.logs); if (!logs.length) writeLine(ctx, 'No audit logs returned; provider availability or permissions may limit this view.'); for (const raw of logs) { const log = asRecord(raw); if (!log) continue; const action = asRecord(log.action); const actor = asRecord(log.actor); const resource = asRecord(log.resource); writeLine(ctx, `${String(log.id ?? '-')} ${String(log.when ?? '-')} ${String(action?.type ?? '-')} ${action?.result === true ? 'success' : action?.result === false ? 'failed' : '-'} ${String(actor?.email ?? actor?.id ?? '-')} ${String(resource?.type ?? '-')} ${String(resource?.id ?? '-')}`); } } else fields(ctx, data); }); } if (subcommand === 'account') { const payload = await withSpinner(ctx, 'Loading Cloudflare account', () => cloudflare.workspace({ ...scope, accountId })); if (payload.success === false) return emit(payload, () => { }); const matches = asArray(dataOf(payload).accounts).map(asRecord).filter((item) => item?.id === accountId); if (!matches.length) throw new Error('Cloudflare account not found in the selected workspace.'); const customerIds = new Set(matches.map((item) => item?.customerId)); if (customerIds.size > 1) throw new Error('This Cloudflare account is linked to multiple customers. Specify --customer-id.'); const account = matches.find((item) => item?.status === 'active' || item?.status === 'trialing') ?? matches[0]; return emit({ success: true, data: account }, (data) => fields(ctx, data)); } if (subcommand === 'zones' || subcommand === 'zone') { if (zoneId && scope.customerId) { const payload = await withSpinner(ctx, 'Loading live Cloudflare zone', () => cloudflare.zoneDetail(scope.customerId, zoneId)); if (payload.success === false) return emit(payload, () => { }); const zone = dataOf(payload); if (zone.id !== zoneId) throw new Error('The server returned a different Cloudflare zone.'); if (scope.accountId && asRecord(zone.account)?.id !== scope.accountId) throw new Error('Cloudflare zone does not belong to the selected account.'); return emit(payload, (data) => fields(ctx, data)); } let payload = await withSpinner(ctx, 'Loading Cloudflare zones', () => scope.customerId || scope.accountId || scope.zoneId || zoneId ? cloudflare.workspace({ ...scope, zoneId: zoneId ?? scope.zoneId }, 'zones') : one.getCloudflareZones()); if (payload.success === false) return emit(payload, () => { }); if (scope.accountId || scope.zoneId) { const data = dataOf(payload); const selectedZones = asArray(data.zones).filter((raw) => { const zone = asRecord(raw); return zone && (!scope.accountId || (zone.accountId ?? zone.account_id ?? zone.cloudflare_account_id) === scope.accountId) && (!scope.zoneId || (zone.id ?? zone.zone_id) === scope.zoneId); }); payload = payload.data ? { ...payload, data: { ...data, zones: selectedZones } } : { ...payload, zones: selectedZones }; } if (zoneId) { const zone = asArray(dataOf(payload).zones).map(asRecord).find((item) => (item?.id ?? item?.zone_id) === zoneId); if (!zone) throw new Error('Cloudflare zone not found in the selected workspace.'); return emit({ success: true, data: zone }, (data) => fields(ctx, data)); } return emit(payload, (data) => zones(ctx, data.zones)); } if (subcommand === 'accounts' || subcommand === 'workspace') { let payload = await withSpinner(ctx, 'Loading Cloudflare workspace', () => cloudflare.workspace(scope, section)); if (subcommand === 'accounts' && scope.accountId) { const data = dataOf(payload); const accounts = asArray(data.accounts).filter((raw) => asRecord(raw)?.id === scope.accountId); payload = { ...payload, data: { ...data, accounts } }; } return emit(payload, (data) => { if (subcommand === 'accounts') { const accounts = asArray(data.accounts); if (!accounts.length) writeLine(ctx, 'No Cloudflare accounts.'); for (const raw of accounts) { const account = asRecord(raw); if (account) writeLine(ctx, `${String(account.id)} ${String(account.name).padEnd(28)} ${String(account.status)} ${String(account.zoneCount ?? 0)} zones${account.customerId ? ` customer ${String(account.customerId)}` : ''}`); } } else fields(ctx, data); }); } if (subcommand === 'dns' || subcommand === 'waf') { const payload = await withSpinner(ctx, `Loading Cloudflare ${subcommand.toUpperCase()}`, () => cloudflare.zoneRecords(subcommand, zoneId, scope.customerId)); return emit(payload, (data) => { const records = asArray(data[subcommand === 'dns' ? 'records' : 'rules']); if (!records.length) writeLine(ctx, `No ${subcommand === 'dns' ? 'DNS records' : 'WAF rules'}.`); for (const raw of records) { const record = asRecord(raw); if (!record) continue; if (subcommand === 'dns') { writeLine(ctx, `${String(record.id)} ${String(record.type).padEnd(6)} ${String(record.name).padEnd(28)} ${String(record.content)} TTL ${String(record.ttl)} ${record.proxied ? 'proxied' : 'DNS only'}`); } else { writeLine(ctx, `${String(record.id)} ${String(record.action).padEnd(18)} ${record.paused ? 'paused' : 'active'} ${String(record.description ?? '')}`); const expression = asRecord(record.filter)?.expression; if (expression) writeLine(ctx, ` ${String(expression)}`); } } if (subcommand === 'waf') { writeLine(ctx, `Rule analytics: ${data.analyticsAvailable ? 'available' : 'unavailable'}`); for (const raw of asArray(data.suggestions)) { const suggestion = asRecord(raw); if (suggestion) writeLine(ctx, `[${String(suggestion.severity)}] ${String(suggestion.title)}: ${String(suggestion.recommendation)}`); } } }); } if (subcommand === 'analytics') { const payload = await withSpinner(ctx, 'Loading Cloudflare analytics', () => cloudflare.analytics(scope, days)); return emit(payload, (data) => { if (scope.zoneId) writeLine(ctx, 'Live zone analytics use the most recent 23 hours; stored usage may cover less.'); fields(ctx, asRecord(data.analytics) ?? data); }); } if (action === 'progress') { const payload = await withSpinner(ctx, 'Loading Cloudflare checkup progress', () => cloudflare.checkupProgress(runId)); return emit(payload, (data) => { writeLine(ctx, `checkup ${String(data.status)} ${String(data.progressPct ?? 0)}%`); for (const raw of asArray(data.events)) { const event = asRecord(raw); if (event) writeLine(ctx, `${String(event.createdAt ?? '')} ${String(event.message ?? '')}`); } if (data.errorMessage) writeLine(ctx, `error ${String(data.errorMessage)}`); }); } if (action === 'result') { const payload = await withSpinner(ctx, 'Loading Cloudflare checkup result', () => one.getToolRun(runId)); const run = dataOf(payload); if (run.tool_name !== 'cloudflare-checkup') throw new Error('This run is not a Cloudflare checkup.'); return emit(payload, (data) => { writeLine(ctx, `checkup ${String(data.status)}`); if (data.grade) writeLine(ctx, `grade ${String(data.grade)} ${String(data.grade_score ?? '')}`); if (data.results) fields(ctx, asRecord(data.results) ?? { results: data.results }); else writeLine(ctx, 'No result is available yet.'); }); } const isStart = action === 'start' || action === 'run'; const payload = await withSpinner(ctx, isStart ? 'Starting Cloudflare checkup' : 'Loading Cloudflare checkup', () => cloudflare.checkup(scope, isStart)); return emit(payload, (data) => { if (isStart) { writeLine(ctx, `started ${String(data.toolRunId ?? data.id ?? 'ok')} ${String(data.status ?? 'running')}`); return; } const run = asRecord(data.latestRun); const report = asRecord(data.report); writeLine(ctx, `checkup ${data.requiresSetup ? 'setup required' : String(run?.status ?? (report ? 'completed' : 'no completed runs'))}`); if (run?.id) writeLine(ctx, `run ${String(run.id)}`); if (run?.grade ?? report?.grade) writeLine(ctx, `grade ${String(run?.grade ?? report?.grade)}`); if (report) fields(ctx, report); }); };