@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
409 lines (408 loc) • 20.7 kB
JavaScript
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;
}
}
}