UNPKG

@vigilbase/cli

Version:

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

409 lines (408 loc) • 20.7 kB
import { parseArgs } from 'node:util'; import { DeviceAuthError } from './auth/index.js'; import { VigilbaseApiError, VigilbaseClient } from './lib/client.js'; import { createConfig, createLocalCommandConfig } from './lib/config.js'; import { renderBanner, shouldShowBanner } from './lib/banner.js'; import { createWriter, formatError } from './lib/format.js'; import { HELP_TAGLINE, HELP_TEXT, HELP_USAGE, helpForCommand, isCatalogTool } from './lib/help.js'; import { wasRendered } from './lib/tui.js'; import { isOneAuthRequiredTool } from './lib/one-client.js'; import { COMMAND_HANDLERS, getCommandHandler } from './commands/index.js'; import { CheckupCancelledError, LocalCheckupError } from './local/cloudflare-checkup/errors.js'; import { EXIT } from './types.js'; import { CLI_VERSION } from './version.js'; export { CLI_VERSION }; const OPTIONS = { json: { type: 'boolean', short: 'j', default: false }, help: { type: 'boolean', short: 'h', default: false }, version: { type: 'boolean', short: 'V', default: false }, 'base-url': { type: 'string' }, 'one-url': { type: 'string' }, 'auth-url': { type: 'string' }, 'client-id': { type: 'string' }, timeout: { type: 'string' }, style: { type: 'string' }, 'no-skill': { type: 'boolean', default: false }, via: { type: 'string' }, public: { type: 'boolean', default: false }, token: { type: 'string' }, 'token-stdin': { type: 'boolean', default: false }, 'no-open': { type: 'boolean', default: false }, email: { type: 'string' }, role: { type: 'string' }, confirm: { type: 'boolean', default: false }, 'account-id': { type: 'string' }, tool: { type: 'string' }, limit: { type: 'string' }, 'target-id': { type: 'string' }, 'customer-id': { type: 'string' }, 'zone-id': { type: 'string' }, 'days': { type: 'string' }, 'offset': { type: 'string' }, 'status': { type: 'string' }, 'severity': { type: 'string' }, 'attack-category': { type: 'string' }, 'order-by': { type: 'string' }, 'order-direction': { type: 'string' }, 'asset-id': { type: 'string' }, 'provider': { type: 'string' }, 'dataset': { type: 'string' }, 'search': { type: 'string' }, 'start': { type: 'string' }, 'end': { type: 'string' }, 'sort': { type: 'string' }, 'frs-min': { type: 'string' }, 'before': { type: 'string' }, 'note': { type: 'string' }, 'cursor': { type: 'string' }, 'action-prefix': { type: 'string' }, 'outcome': { type: 'string' }, 'hours': { type: 'string' }, 'metrics': { type: 'string' }, 'min-weight': { type: 'string' }, 'range': { type: 'string' }, 'region': { type: 'string' }, 'issue-id': { type: 'string' }, 'revision': { type: 'string' }, all: { type: 'boolean', default: false }, light: { type: 'boolean', default: false }, profile: { type: 'string' }, 'cf-auth': { type: 'string' }, 'cf-bin': { type: 'string' }, out: { type: 'string' }, overwrite: { type: 'boolean', default: false }, 'dry-run': { type: 'boolean', default: false }, 'max-requests-per-5min': { type: 'string' }, yes: { type: 'boolean', default: false }, }; function parseCli(argv) { const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, strict: true, options: OPTIONS, }); return { positionals, values: { 'customer-id': typeof values['customer-id'] === 'string' ? values['customer-id'] : undefined, 'zone-id': typeof values['zone-id'] === 'string' ? values['zone-id'] : undefined, 'days': typeof values['days'] === 'string' ? values['days'] : undefined, 'offset': typeof values['offset'] === 'string' ? values['offset'] : undefined, 'status': typeof values['status'] === 'string' ? values['status'] : undefined, 'severity': typeof values['severity'] === 'string' ? values['severity'] : undefined, 'attack-category': typeof values['attack-category'] === 'string' ? values['attack-category'] : undefined, 'order-by': typeof values['order-by'] === 'string' ? values['order-by'] : undefined, 'order-direction': typeof values['order-direction'] === 'string' ? values['order-direction'] : undefined, 'asset-id': typeof values['asset-id'] === 'string' ? values['asset-id'] : undefined, 'provider': typeof values['provider'] === 'string' ? values['provider'] : undefined, 'dataset': typeof values['dataset'] === 'string' ? values['dataset'] : undefined, 'search': typeof values['search'] === 'string' ? values['search'] : undefined, 'start': typeof values['start'] === 'string' ? values['start'] : undefined, 'end': typeof values['end'] === 'string' ? values['end'] : undefined, 'sort': typeof values['sort'] === 'string' ? values['sort'] : undefined, 'frs-min': typeof values['frs-min'] === 'string' ? values['frs-min'] : undefined, 'before': typeof values['before'] === 'string' ? values['before'] : undefined, 'note': typeof values['note'] === 'string' ? values['note'] : undefined, 'cursor': typeof values['cursor'] === 'string' ? values['cursor'] : undefined, 'action-prefix': typeof values['action-prefix'] === 'string' ? values['action-prefix'] : undefined, 'outcome': typeof values['outcome'] === 'string' ? values['outcome'] : undefined, 'hours': typeof values['hours'] === 'string' ? values['hours'] : undefined, 'metrics': typeof values['metrics'] === 'string' ? values['metrics'] : undefined, 'min-weight': typeof values['min-weight'] === 'string' ? values['min-weight'] : undefined, 'range': typeof values['range'] === 'string' ? values['range'] : undefined, 'region': typeof values['region'] === 'string' ? values['region'] : undefined, 'issue-id': typeof values['issue-id'] === 'string' ? values['issue-id'] : undefined, 'revision': typeof values['revision'] === 'string' ? values['revision'] : undefined, isAll: Boolean(values.all), isLight: Boolean(values.light), isJson: Boolean(values.json), isHelp: Boolean(values.help), isVersion: Boolean(values.version), 'base-url': typeof values['base-url'] === 'string' ? values['base-url'] : undefined, 'one-url': typeof values['one-url'] === 'string' ? values['one-url'] : undefined, 'auth-url': typeof values['auth-url'] === 'string' ? values['auth-url'] : undefined, 'client-id': typeof values['client-id'] === 'string' ? values['client-id'] : undefined, timeout: typeof values.timeout === 'string' ? values.timeout : undefined, style: typeof values.style === 'string' ? values.style : undefined, isNoSkill: Boolean(values['no-skill']), via: typeof values.via === 'string' ? values.via : undefined, isPublic: Boolean(values.public), token: typeof values.token === 'string' ? values.token : undefined, isTokenStdin: Boolean(values['token-stdin']), isNoOpen: Boolean(values['no-open']), email: typeof values.email === 'string' ? values.email : undefined, role: typeof values.role === 'string' ? values.role : undefined, isConfirm: Boolean(values.confirm), 'account-id': typeof values['account-id'] === 'string' ? values['account-id'] : undefined, tool: typeof values.tool === 'string' ? values.tool : undefined, limit: typeof values.limit === 'string' ? values.limit : undefined, 'target-id': typeof values['target-id'] === 'string' ? values['target-id'] : undefined, profile: typeof values.profile === 'string' ? values.profile : undefined, 'cf-auth': typeof values['cf-auth'] === 'string' ? values['cf-auth'] : undefined, 'cf-bin': typeof values['cf-bin'] === 'string' ? values['cf-bin'] : undefined, out: typeof values.out === 'string' ? values.out : undefined, isOverwrite: Boolean(values.overwrite), isDryRun: Boolean(values['dry-run']), 'max-requests-per-5min': typeof values['max-requests-per-5min'] === 'string' ? values['max-requests-per-5min'] : undefined, isYes: Boolean(values.yes), }, }; } function parseTimeout(raw) { if (raw === undefined) return undefined; const value = Number(raw); if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) { throw new Error('--timeout must be an integer between 1 and 2147483647 milliseconds.'); } return value; } function resolveMode(jsonFlag) { return jsonFlag ? 'json' : 'text'; } /** Arguments before `--`: everything after it is a positional. */ function optionArguments(argv) { const end = argv.indexOf('--'); return end === -1 ? argv : argv.slice(0, end); } /** JSON mode for an error from parsing itself: --json, --json=… or a short-flag cluster with j. */ function requestsJson(argv) { return optionArguments(argv).some((arg) => /^--json(=|$)/.test(arg) || /^-[A-Za-z]*j[A-Za-z]*$/.test(arg)); } /** Edit distance counting a swap of two neighbouring letters as one edit. */ function editDistance(a, b) { const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i ? (j ? 0 : i) : j))); for (let i = 1; i <= a.length; i++) { for (let j = 1; j <= b.length; j++) { d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1); } } return d[a.length][b.length]; } /** The nearest candidate when it is close enough to be a typo: one edit for short words, two from six letters. */ function didYouMean(input, candidates) { let best; let bestDistance = Math.min(2, Math.max(1, Math.floor(input.length / 3))) + 1; for (const candidate of candidates) { const distance = editDistance(input, candidate); if (distance < bestDistance) [best, bestDistance] = [candidate, distance]; } return best; } /** Commands with subcommands, whose help covers them all. */ const COMMAND_GROUPS = new Set(['account', 'billing', 'runs', 'runhistory', 'cf', 'cloudflare', 'watchdog', 'security', 'customers', 'orgs', 'monitoring', 'support', 'local', 'cloudflare-checkup']); const USAGE_POINTER = 'Run `vigilbase help` for usage.'; function cliError(message, code) { return Object.assign(new Error(message), { code }); } function unknownCommand(typed) { const name = typed.toLowerCase(); const command = didYouMean(name, [...Object.keys(COMMAND_HANDLERS), 'help']); const suggestion = command ?? (name === 'version' || name === 'json' ? `--${name}` : undefined); return new Error(`Unknown command "${typed}".${suggestion ? ` Did you mean ${suggestion}?` : ''} ${USAGE_POINTER}`); } function unknownOption(argv, reported) { const longNames = Object.keys(OPTIONS); let name = reported; let suggestion; // parseArgs reads -help as -h -e -l -p: name the whole word and suggest the long option. const word = optionArguments(argv).find((arg) => /^-[^-]{2,}$/.test(arg) && arg.includes(reported.slice(1)) && didYouMean(arg.slice(1), longNames)); if (word) { name = word; suggestion = `--${didYouMean(word.slice(1), longNames)}`; } else if (reported.startsWith('--')) { const option = didYouMean(reported.slice(2).split('=')[0], longNames); if (option) suggestion = `--${option}`; } else { const letter = reported.slice(1); const short = Object.values(OPTIONS).find((option) => 'short' in option && option.short.toLowerCase() === letter.toLowerCase()); if (short && 'short' in short) suggestion = `-${short.short}`; } return cliError(`Unknown option ${name}.${suggestion ? ` Did you mean ${suggestion}?` : ''} ${USAGE_POINTER}`, 'UNKNOWN_OPTION'); } /** node:util parseArgs errors in the CLI's words, with the CLI's own codes. */ function argumentError(error, argv) { const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; if (typeof code !== 'string' || !code.startsWith('ERR_PARSE_ARGS_')) return error; const message = error instanceof Error ? error.message : ''; if (code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') return unknownOption(argv, /'([^']+)'/.exec(message)?.[1] ?? ''); const flag = /--[a-z0-9-]+/.exec(message)?.[0]; if (!flag) return cliError(`Invalid arguments. ${USAGE_POINTER}`, 'INVALID_ARGUMENT'); if (message.includes('does not take an argument')) return cliError(`${flag} does not take a value.`, 'INVALID_ARGUMENT'); if (message.includes('argument missing')) return cliError(`${flag} needs a value.`, 'INVALID_ARGUMENT'); if (message.includes('ambiguous')) { return cliError(`${flag} needs a value. To pass one that starts with a dash, use ${flag}=<value>.`, 'INVALID_ARGUMENT'); } return cliError(`Invalid value for ${flag}. ${USAGE_POINTER}`, 'INVALID_ARGUMENT'); } /** Public tool commands: they contact One only to run a tool through it. */ const PUBLIC_TOOL_COMMANDS = new Set(['list', 'tools', 'run', 'headers', 'email', 'describe', 'skill', 'badge']); /** * The origins a command contacts, so settings for the others can't fail it. Doctor checks both. `list` reads a * saved sign-in offline and only warns about one it can't use. */ function originsUsed(command, rest, values) { if (!PUBLIC_TOOL_COMMANDS.has(command)) return { public: command === 'doctor', one: true }; if (command !== 'run' && command !== 'headers' && command !== 'email') return { public: true, one: false }; const toolName = command === 'headers' ? 'security-headers' : command === 'email' ? 'email-security' : rest[0] ?? ''; const isForcedPublic = values.isPublic || values.via === 'public'; const isViaOne = !isForcedPublic && (values.via === 'one' || isOneAuthRequiredTool(toolName)); return { public: !isViaOne, one: isViaOne }; } export async function runCli(options = {}) { const argv = options.argv ?? process.argv.slice(2); const stdoutStream = options.stdout ?? process.stdout; const writer = createWriter(stdoutStream, options.stderr); const previousEnv = process.env; if (options.env) { process.env = options.env; } let mode; try { let parsed; try { parsed = parseCli(argv); } catch (error) { throw argumentError(error, argv); } mode = resolveMode(parsed.values.isJson); // Commands are matched in any case, as subcommands are. const [typed, ...rest] = parsed.positionals; const command = typed?.toLowerCase(); if (parsed.values.isVersion || parsed.values.isHelp || !command || command === 'help') { const informational = new Set(['isJson', 'isHelp', 'isVersion', 'base-url', 'one-url', 'auth-url', 'client-id', 'timeout']); for (const [key, value] of Object.entries(parsed.values)) { if (value === undefined || value === false || informational.has(key)) continue; const flag = key.startsWith('is') ? key.slice(2).replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() : key; throw new Error(`--${flag} is not supported with help or version.`); } if (parsed.values.isVersion && parsed.positionals.length) throw new Error('Usage: vigilbase --version'); // `help <group> <subcommand>` shows the group's help, as `<group> <subcommand> --help` does. if (command === 'help' && rest.length > 1 && !COMMAND_GROUPS.has(rest[0].toLowerCase())) throw new Error(HELP_USAGE); } if (parsed.values.isVersion) { if (mode === 'json') { writer.stdout(JSON.stringify({ name: '@vigilbase/cli', version: CLI_VERSION }, null, 2)); } else if (!stdoutStream.isTTY) { // Pipes and files get only the version, for scripts. writer.stdout(CLI_VERSION); } else if (shouldShowBanner(mode)) { writer.stdout(renderBanner({ version: CLI_VERSION, tagline: HELP_TAGLINE })); } else { writer.stdout(`@vigilbase/cli ${CLI_VERSION}`); } return EXIT.OK; } // `help --help` is help's own help, like `help help`. const helpTyped = command === 'help' ? rest[0] ?? (parsed.values.isHelp ? 'help' : undefined) : parsed.values.isHelp ? typed : undefined; const helpCommand = helpTyped?.toLowerCase(); if (parsed.values.isHelp || !command || command === 'help' || command === '--help') { const specific = helpCommand ? helpForCommand(helpCommand) : null; if (helpCommand && !specific) { throw unknownCommand(helpTyped); } if (mode === 'json') { writer.stdout(JSON.stringify({ name: '@vigilbase/cli', version: CLI_VERSION, help: specific ?? HELP_TEXT, command: helpCommand ?? null, }, null, 2)); } else if (specific) { writer.stdout(specific); } else { // Like --version, pipes and files get no banner. const prefix = shouldShowBanner(mode) && stdoutStream.isTTY ? `${renderBanner({ version: CLI_VERSION, tagline: HELP_TAGLINE })}\n\n` : ''; writer.stdout(`${prefix}${HELP_TEXT}`); } return EXIT.OK; } const handler = getCommandHandler(command); if (!handler) { // Tools are not commands of their own (no aliases): point to how they run. throw isCatalogTool(command) ? new Error(`Unknown command "${typed}". Run the tool with \`vigilbase run ${command} <target>\`.`) : unknownCommand(typed); } // Local checkups validate --base-url themselves (resolveSubmitOrigin) and ignore VIGILBASE_* URLs. const config = command === 'local' || command === 'cloudflare-checkup' ? createLocalCommandConfig(parseTimeout(parsed.values.timeout)) : createConfig({ baseUrl: parsed.values['base-url'], oneBaseUrl: parsed.values['one-url'], identityBaseUrl: parsed.values['auth-url'], clientId: parsed.values['client-id'], timeoutMs: parseTimeout(parsed.values.timeout), checkOrigins: originsUsed(command, rest, parsed.values), }); const publicClient = new VigilbaseClient(config); return await handler({ command, rest, values: parsed.values, mode, config, writer, publicClient, env: process.env, stdin: options.stdin, stdoutStream, stderrStream: options.stderr ?? process.stderr, loginHandler: options.loginHandler, }); } catch (error) { mode ??= requestsJson(argv) ? 'json' : 'text'; // The terminal UI already showed it (■ and the hint) in its timeline. if (mode === 'json' || !wasRendered(error)) formatError(error, mode, writer); if ((error instanceof VigilbaseApiError || error instanceof DeviceAuthError) && error.status === 429) { return EXIT.RATE_LIMITED; } if (error instanceof LocalCheckupError && error.isRateLimited) { return EXIT.RATE_LIMITED; } // A cf run or install stopped by a signal: 128 + the signal number. if (error instanceof CheckupCancelledError) return error.exitCode; return EXIT.ERROR; } finally { if (options.env) { process.env = previousEnv; } } }