@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
403 lines (402 loc) • 25.8 kB
JavaScript
import { z } from 'zod';
import { SecurityClient } from '../lib/security-client.js';
import { assertCommandFlags, asArray, asRecord, ok, outputJson, requireOneClient, withSpinner } from './helpers.js';
import { missingArguments } from './legacy-validation.js';
const statuses = ['open', 'active', 'acknowledged', 'investigating', 'mitigating', 'mitigated', 'resolved', 'false_positive'];
const updateStatuses = ['acknowledged', 'mitigating', 'resolved', 'false_positive'];
function textFlag(ctx, flag) {
const value = ctx.values[flag];
if (value === undefined)
return undefined;
return z.string().trim().min(1, `--${flag} must not be empty.`).max(2048).parse(value);
}
function integer(ctx, flag, max, fallback) {
const value = textFlag(ctx, flag);
if (value === undefined)
return fallback;
if (!/^\d+$/.test(value))
throw new Error(`--${flag} must be an integer.`);
return z.number().int().min(['limit', 'hours', 'min-weight'].includes(flag) ? 1 : 0).max(max).parse(Number(value));
}
function allowed(ctx, flags) {
assertCommandFlags(ctx, [...flags, 'customer-id']);
}
/** `usage` follows `vigilbase security` and ends with the positional placeholders, so a missing one is named. */
function arity(ctx, length, usage) {
if (ctx.rest.length < length)
throw new Error(missingArguments('security', length - ctx.rest.length, usage && `vigilbase security ${usage}`));
if (ctx.rest.length > length)
throw new Error('Unexpected security arguments. Run `vigilbase security --help` for usage.');
}
function uuid(value) {
const parsed = z.string().uuid().safeParse(value);
if (!parsed.success)
throw new Error('A valid UUID is required. Copy the full ID from the list command.');
return parsed.data;
}
/** Explicit read catalog: no arbitrary endpoint or query passthrough. */
function extendedSecurityRequest(ctx, scope) {
const [group, sub, id, childId] = ctx.rest;
const request = (path, view, query = {}) => ({ path, view, query, ...scope });
const window = (flags = []) => {
allowed(ctx, ['hours', 'start', 'end', 'asset-id', ...flags]);
const start = textFlag(ctx, 'start'), end = textFlag(ctx, 'end');
if (Boolean(start) !== Boolean(end))
throw new Error('--start and --end must be supplied together as ISO timestamps.');
if (start && end) {
z.iso.datetime({ offset: true }).parse(start);
z.iso.datetime({ offset: true }).parse(end);
if (Date.parse(start) >= Date.parse(end))
throw new Error('--start must be before --end.');
if (ctx.values.hours !== undefined)
throw new Error('Use either --hours or --start with --end.');
}
return {
hours: start ? undefined : integer(ctx, 'hours', 720, 24), start, end,
asset_id: ctx.values['asset-id'] !== undefined ? uuid(ctx.values['asset-id']) : undefined,
};
};
const graph = (isIncident) => {
allowed(ctx, ['dataset', 'min-weight', 'limit', ...(isIncident ? [] : ['hours'])]);
return { dataset: textFlag(ctx, 'dataset'), min_weight: integer(ctx, 'min-weight', 1_000_000, 1), limit: integer(ctx, 'limit', 1000, 200), ...(isIncident ? {} : { window_hours: integer(ctx, 'hours', 720, 24) }) };
};
if (group === 'analytics') {
const operation = sub ?? 'overview';
const views = {
overview: 'analytics', 'security-analysis': 'analyticsRows', 'bot-activity': 'analyticsRows', 'top-attacker-ips': 'analyticsRows', 'bot-asns': 'analyticsRows', 'dns-security': 'dnsSecurity', 'page-shield': 'analyticsRows',
};
arity(ctx, sub === undefined ? 1 : 2);
if (operation === 'provider-events') {
allowed(ctx, ['provider', 'dataset', 'start', 'end', 'asset-id', 'search', 'limit']);
const query = window(['provider', 'dataset', 'search', 'limit']);
if (!query.start || !query.end)
throw new Error('Provider events require --start and --end.');
const provider = textFlag(ctx, 'provider'), dataset = textFlag(ctx, 'dataset');
if (!provider || !dataset)
throw new Error('Provider events require --provider and --dataset.');
const search = textFlag(ctx, 'search');
if (search && search.length > 200)
throw new Error('--search must be at most 200 characters for provider events.');
return request('/analytics/provider-events', 'providerEvents', { provider_id: provider, dataset_id: dataset, start: query.start, end: query.end, asset_ids: query.asset_id, search, limit: integer(ctx, 'limit', 500, 200) });
}
const view = views[operation];
if (!view)
throw new Error(`Unknown analytics command: ${operation}`);
const query = window(operation === 'overview' ? ['metrics'] : []);
if (operation === 'overview') {
const metrics = textFlag(ctx, 'metrics') ?? 'hourly,by_domain,by_dataset';
const permitted = new Set(['summary', 'hourly', 'by_domain', 'by_dataset', 'response_status', 'waf_scores', 'alerts', 'geographic', 'top_paths', 'waf_rules', 'performance']);
if (metrics.split(',').some((metric) => !permitted.has(metric)))
throw new Error('Unsupported --metrics value. See SECURITY.md for supported metrics.');
query.metrics = metrics;
}
return request(operation === 'overview' ? '/analytics' : `/analytics/${operation}`, view, query);
}
if (group === 'zero-trust') {
const views = { 'device-health': 'deviceHealth', 'app-tests': 'appTests', gateway: 'gateway', access: 'access', sessions: 'sessions', 'device-posture': 'devicePosture' };
if (!sub || !views[sub])
throw new Error('Choose a zero-trust command: device-health, app-tests, gateway, access, sessions, device-posture.');
arity(ctx, sub === 'gateway' ? 3 : 2, 'zero-trust gateway <dns|http|network>');
const query = window();
if (sub === 'gateway')
query.surface = z.enum(['dns', 'http', 'network']).parse(id);
return request(`/zero-trust/${sub}`, views[sub], query);
}
if (group === 'cloudflare-account') {
const operation = sub ?? 'inventory';
const views = { inventory: 'accountInventory', audit: 'accountAudit', ingestion: 'accountIngestion', posture: 'accountPosture' };
if (!views[operation])
throw new Error(`Unknown Cloudflare account security command: ${operation}`);
arity(ctx, sub === undefined ? 1 : 2);
let query = {};
if (operation === 'inventory')
allowed(ctx, []);
else
query = window();
return request(`/cloudflare-account/${operation}`, views[operation], query);
}
if (group === 'providers') {
arity(ctx, 1);
allowed(ctx, []);
return request('/providers', 'providers', { connectable: false });
}
if (group === 'notifications') {
const operation = sub ?? 'list';
const routes = { list: ['/notifications', 'notifications'], settings: ['/notifications/settings', 'notificationSettings'], preferences: ['/notifications/preferences', 'preferences'], 'slack-channels': ['/notifications/slack/channels', 'slackChannels'] };
const route = routes[operation];
if (!route)
throw new Error(`Unknown notifications command: ${operation}`);
arity(ctx, sub === undefined ? 1 : 2);
allowed(ctx, operation === 'list' ? ['limit'] : []);
return request(...route, operation === 'list' ? { limit: integer(ctx, 'limit', 100, 100) } : {});
}
if (group === 'assets' && sub !== undefined) {
const routes = {
allowlist: (assetId) => [`/domains/${assetId}/allowlist`, 'ipList'], blocklist: (assetId) => [`/domains/${assetId}/blocklist`, 'ipList'],
remediation: (assetId) => [`/assets/${assetId}/remediation-settings`, 'assetRemediation'], autonomy: (assetId) => [`/assets/${assetId}/vigil-autonomy`, 'autonomy'],
datasets: (assetId) => [`/fabric/assets/${assetId}/datasets`, 'datasets'], graph: (assetId) => [`/fabric/assets/${assetId}/graph`, 'graph'],
};
const route = Object.hasOwn(routes, sub) ? routes[sub] : undefined;
if (!route)
throw new Error(`Unknown assets command: ${sub}`);
arity(ctx, 3, `assets ${sub} <asset-id>`);
if (sub !== 'graph')
allowed(ctx, []);
return request(...route(uuid(id)), sub === 'graph' ? graph(false) : {});
}
if (group === 'fabric') {
if (sub !== 'default-asset')
throw new Error('Usage: vigilbase security fabric default-asset');
arity(ctx, 2);
allowed(ctx, []);
return request('/fabric/default-asset', 'defaultAsset');
}
if (group === 'connections' && sub && ['available-assets', 'remediation', 'logpush-status', 'worker-stats'].includes(sub)) {
const isJob = sub === 'logpush-status' || sub === 'worker-stats';
arity(ctx, isJob ? 4 : 3, isJob ? `connections ${sub} <connection-id> <job-id>` : `connections ${sub} <id>`);
allowed(ctx, []);
const connectionId = uuid(id);
if (sub === 'available-assets')
return request(`/connections/${connectionId}/available-assets`, 'availableAssets');
if (sub === 'remediation')
return request(`/connections/${connectionId}/remediation-settings`, 'connectionRemediation');
return sub === 'logpush-status' ? request(`/connections/${connectionId}/logpush-jobs/${uuid(childId)}/status`, 'logpushStatus') : request(`/connections/${connectionId}/worker-routes/${uuid(childId)}/stats`, 'workerStats');
}
if (group === 'reports' && sub === 'settings') {
arity(ctx, 2);
allowed(ctx, []);
return request('/reports/settings', 'reportSettings');
}
if (group === 'alerts') {
if (sub === 'show' || sub === 'get') {
arity(ctx, 3, `alerts ${sub} <id>`);
allowed(ctx, []);
return request(`/alerts/${uuid(id)}`, 'alert');
}
if (sub !== undefined && sub !== 'list')
throw new Error(`Unknown alerts command: ${sub}`);
arity(ctx, sub === undefined ? 1 : 2);
allowed(ctx, ['limit', 'asset-id', 'start']);
const since = textFlag(ctx, 'start');
if (since)
z.iso.datetime({ offset: true }).parse(since);
return request('/alerts', 'alerts', { limit: integer(ctx, 'limit', 100, 100), asset_id: ctx.values['asset-id'] !== undefined ? uuid(ctx.values['asset-id']) : undefined, since });
}
if (group === 'incidents' && (sub === 'fabric' || sub === 'share-links')) {
arity(ctx, 3, `incidents ${sub} <id>`);
if (sub === 'share-links')
allowed(ctx, []);
return request(`/incidents/${uuid(id)}/${sub}`, sub === 'fabric' ? 'graph' : 'shareLinks', sub === 'fabric' ? graph(true) : {});
}
if (group === 'settings') {
const routes = { 'lifecycle-emails': ['/settings/lifecycle-emails', 'lifecycleEmails'], 'broadcast-emails': ['/settings/broadcast-emails', 'broadcastEmails'], applications: ['/integrations/grants', 'applications'] };
const route = sub && routes[sub];
if (!route)
throw new Error('Choose settings lifecycle-emails, broadcast-emails, or applications.');
arity(ctx, 2);
allowed(ctx, []);
return request(...route);
}
return null;
}
export function buildSecurityRequest(ctx) {
const group = ctx.rest[0] ?? 'overview';
const command = ctx.rest[1] ?? 'list';
const customerId = textFlag(ctx, 'customer-id');
const scope = customerId ? { customerId: uuid(customerId) } : {};
const extended = extendedSecurityRequest(ctx, scope);
if (extended)
return extended;
const request = (path, view, query = {}) => ({ path, view, query, ...scope });
if (group === 'overview') {
arity(ctx, ctx.rest.length === 0 ? 0 : 1);
allowed(ctx, []);
return request('/dashboard/overview', 'overview');
}
if (['incidents', 'connections', 'reports'].includes(group)) {
if (command === 'show' || command === 'get') {
arity(ctx, 3, `${group} ${command} <id>`);
allowed(ctx, []);
return request(`/${group}/${uuid(ctx.rest[2])}`, group === 'incidents' ? 'incident' : group === 'connections' ? 'connection' : 'report');
}
if (group === 'incidents' && command === 'timeline') {
arity(ctx, 3, 'incidents timeline <id>');
allowed(ctx, ['before', 'limit']);
return request(`/incidents/${uuid(ctx.rest[2])}/timeline`, 'timeline', { limit: integer(ctx, 'limit', 200, 100), before: textFlag(ctx, 'before') });
}
if (group === 'incidents' && command === 'update') {
arity(ctx, 4, 'incidents update <id> <status>');
allowed(ctx, ['note', 'isConfirm']);
const id = uuid(ctx.rest[2]);
const status = z.enum(updateStatuses).parse(ctx.rest[3]);
if (!ctx.values.isConfirm)
throw new Error('Updating an incident requires --confirm. Your One permissions still apply.');
return { ...request(`/incidents/${id}`, 'update'), body: { status, ...(ctx.values.note !== undefined ? { note: textFlag(ctx, 'note') } : {}) } };
}
if (command !== 'list' && command !== 'ls')
throw new Error(`Unknown security ${group} command: ${command}`);
arity(ctx, ctx.rest[1] === undefined ? 1 : 2);
if (group === 'connections') {
allowed(ctx, []);
return request('/connections', 'connections');
}
if (group === 'reports') {
allowed(ctx, ['limit', 'offset']);
return request('/reports', 'reports', { limit: integer(ctx, 'limit', 100, 50), offset: integer(ctx, 'offset', 1_000_000) });
}
allowed(ctx, ['limit', 'offset', 'status', 'severity', 'attack-category', 'order-by', 'order-direction']);
return request('/incidents', 'incidents', {
limit: integer(ctx, 'limit', 100, 50), offset: integer(ctx, 'offset', 1_000_000),
status: z.enum(statuses).optional().parse(textFlag(ctx, 'status')),
severity: z.enum(['critical', 'high', 'medium', 'low']).optional().parse(textFlag(ctx, 'severity')),
attack_category: textFlag(ctx, 'attack-category'),
order_by: z.enum(['last_alert_at', 'first_alert_at', 'created_at', 'severity_rank', 'combined_score', 'alert_count']).optional().parse(textFlag(ctx, 'order-by')),
order_direction: z.enum(['asc', 'desc']).optional().parse(textFlag(ctx, 'order-direction')),
});
}
if (group === 'assets') {
arity(ctx, 1);
allowed(ctx, []);
return request('/domains', 'assets');
}
if (group === 'detections') {
// Detections are configured per asset; the API has no tenant-wide list.
arity(ctx, 1);
allowed(ctx, ['asset-id']);
if (ctx.values['asset-id'] === undefined)
throw new Error('security detections requires --asset-id <uuid>. List asset IDs with `vigilbase security assets`.');
return request('/detections', 'detections', { asset_id: uuid(ctx.values['asset-id']) });
}
if (group === 'logs') {
arity(ctx, 1);
allowed(ctx, ['limit', 'asset-id', 'provider', 'dataset', 'search', 'start', 'end', 'sort', 'frs-min', 'cursor']);
const start = textFlag(ctx, 'start'), end = textFlag(ctx, 'end');
if (Boolean(start) !== Boolean(end))
throw new Error('--start and --end must be supplied together as ISO timestamps.');
if (start && end) {
z.iso.datetime({ offset: true }).parse(start);
z.iso.datetime({ offset: true }).parse(end);
if (Date.parse(start) >= Date.parse(end))
throw new Error('--start must be before --end.');
}
return request('/logs', 'logs', {
limit: integer(ctx, 'limit', 1000, 100), asset_id: ctx.values['asset-id'] !== undefined ? uuid(ctx.values['asset-id']) : undefined,
provider: textFlag(ctx, 'provider'), dataset: textFlag(ctx, 'dataset'), search: textFlag(ctx, 'search'),
start_time: start, end_time: end, cursor: textFlag(ctx, 'cursor'),
sort: z.enum(['ts', 'frs', 'frs_desc']).optional().parse(textFlag(ctx, 'sort')),
frs_min: integer(ctx, 'frs-min', 100),
});
}
if (group === 'audit') {
arity(ctx, 1);
allowed(ctx, ['limit', 'cursor', 'action-prefix', 'outcome']);
return request('/vigilbase-one/audit-events', 'audit', {
limit: integer(ctx, 'limit', 100, 50), cursor: textFlag(ctx, 'cursor'), action_prefix: textFlag(ctx, 'action-prefix'),
outcome: z.enum(['success', 'failure', 'denied']).optional().parse(textFlag(ctx, 'outcome')),
});
}
if (group === 'intel') {
allowed(ctx, []);
if (command === 'quota') {
arity(ctx, 2);
return request('/intel/quota', 'quota');
}
arity(ctx, 3, 'intel <ip|asn|cidr|ja3|ja4> <query>');
const type = z.enum(['ip', 'asn', 'cidr', 'ja3', 'ja4']).parse(command);
const q = z.string().trim().min(1).max(512).parse(ctx.rest[2]);
return request('/intel/lookup', 'intel', { q, type });
}
throw new Error(`Unknown security command: ${group}. Run \`vigilbase security --help\`.`);
}
function cell(value) {
// Security evidence may contain attacker-controlled text; keep each terminal row literal.
return (value === undefined || value === null ? '-' : typeof value === 'object' ? JSON.stringify(value) : String(value))
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
}
function first(row, keys) {
for (const key of keys)
if (row[key] !== undefined && row[key] !== null)
return row[key];
return undefined;
}
function display(ctx, request, payload) {
const columns = {
incidents: [['id', 'incident_id'], ['severity'], ['status'], ['ai_title', 'title'], ['last_alert_at', 'updated_at']],
timeline: [['ts', 'timestamp', 'created_at'], ['kind'], ['changed_by_display_name', 'user_display_name', 'actor'], ['content', 'message', 'description', 'new_status']],
logs: [['ts', 'timestamp'], ['provider'], ['dataset_id', 'dataset'], ['action', 'outcome'], ['client_ip', 'ip', 'principal_email'], ['host', 'resource'], ['path', 'uri'], ['frs', 'frs_risk']],
assets: [['asset_id', 'id'], ['name', 'identifier', 'domain'], ['provider'], ['status']],
detections: [['id'], ['name'], ['enabled'], ['category'], ['datasets']],
connections: [['id'], ['name'], ['provider'], ['status'], ['last_sync_at']],
reports: [['id'], ['status'], ['report_kind'], ['period_start'], ['period_end']],
audit: [['created_at'], ['action'], ['outcome'], ['actor_email_snapshot', 'actor_id'], ['resource_type'], ['resource_id']],
};
const keys = {
incidents: ['incidents'], timeline: ['events'], logs: ['logs'], assets: ['domains'], detections: ['rules', 'detections'], connections: ['connections'], reports: ['reports'], audit: ['events'],
};
const rowKeys = keys[request.view], fields = columns[request.view];
if (rowKeys && fields) {
const rows = asArray(first(payload, rowKeys));
ctx.writer.stdout(`${rows.length} ${request.view}${typeof payload.total === 'number' ? ` (${payload.total} total)` : ''}`);
if (rows.length)
ctx.writer.stdout(fields.map((column) => column[0]).join(' '));
for (const raw of rows) {
const row = asRecord(raw);
if (row)
ctx.writer.stdout(fields.map((column) => cell(first(row, column))).join(' '));
}
const cursor = first(payload, ['next_cursor', 'nextCursor']);
if (cursor)
ctx.writer.stdout(`Next page: --${request.view === 'timeline' ? 'before' : 'cursor'} ${cell(cursor)}`);
else if (payload.has_more || payload.hasMore)
ctx.writer.stdout(`More results available${request.view === 'incidents' ? `: --offset ${Number(request.query.offset ?? 0) + rows.length}` : '.'}`);
if (payload.degraded === true)
ctx.writer.stdout('Warning: timeline data is degraded; some events may be unavailable.');
return;
}
if (request.view === 'update') {
ctx.writer.stdout(`Incident ${cell(request.path.split('/').at(-1))}: ${cell(payload.status)}`);
if (payload.feedback_sync_error)
ctx.writer.stdout(`Warning: status changed, but signal feedback synchronization failed: ${cell(payload.feedback_sync_error)}`);
return;
}
if (request.view === 'overview') {
const overview = asRecord(payload.overview) ?? {};
for (const [key, value] of Object.entries(asRecord(overview.stats) ?? {}))
ctx.writer.stdout(`${key} ${cell(value)}`);
if (overview.latestIngestAt)
ctx.writer.stdout(`Latest ingest ${cell(overview.latestIngestAt)}`);
return;
}
if (payload.unavailable === true)
ctx.writer.stdout(`Unavailable: ${cell(payload.reason ?? 'This source is unavailable for the selected window.')}`);
const detail = asRecord(payload[request.view]) ?? (request.view === 'quota' ? asRecord(payload.quota) : null) ?? payload;
printDetails(ctx, detail);
}
function printDetails(ctx, detail, indent = '') {
for (const [key, value] of Object.entries(detail)) {
if (Array.isArray(value)) {
ctx.writer.stdout(`${indent}${cell(key)} (${value.length})`);
for (const item of value) {
const row = asRecord(item);
ctx.writer.stdout(`${indent} ${row ? Object.entries(row).map(([k, v]) => `${cell(k)}=${cell(v)}`).join(' ') : cell(item)}`);
}
}
else if (asRecord(value)) {
ctx.writer.stdout(`${indent}${cell(key)}`);
printDetails(ctx, asRecord(value), `${indent} `);
}
else
ctx.writer.stdout(`${indent}${cell(key)} ${cell(value)}`);
}
}
export const handleSecurity = async (ctx) => {
const request = buildSecurityRequest(ctx);
const client = new SecurityClient(await requireOneClient(ctx));
const payload = await withSpinner(ctx, `Loading security ${request.view}`, () => client.request(request));
if (ctx.mode === 'json')
outputJson(ctx, payload);
else
display(ctx, request, payload);
return ok();
};
export const SECURITY_HELP = "vigilbase security \u2014 managed security in Vigilbase One\n\n [overview]\n incidents [list] --status --severity --attack-category\n --order-by --order-direction --limit --offset\n incidents show <id>\n incidents timeline <id> --limit --before\n incidents fabric <id> --dataset --min-weight --limit\n incidents share-links <id>\n incidents update <id> <status> --confirm [--note]\n alerts [list] --limit --asset-id --start\n alerts show <id>\n logs --limit --asset-id --provider --dataset --search\n --start --end --sort --frs-min --cursor\n audit --limit --cursor --action-prefix --outcome\n assets\n assets <allowlist|blocklist|remediation|autonomy|datasets> <asset-id>\n assets graph <asset-id> --dataset --hours --min-weight --limit\n fabric default-asset\n detections --asset-id <asset-id>\n providers\n connections [list]\n connections show <id>\n connections <available-assets|remediation> <id>\n connections <logpush-status|worker-stats> <connection-id> <job-id>\n analytics [overview] --metrics + time-window flags\n analytics <security-analysis|bot-activity|top-attacker-ips|bot-asns|dns-security|page-shield>\n analytics provider-events --provider --dataset --start --end\n [--asset-id --search --limit]\n zero-trust <device-health|app-tests|access|sessions|device-posture>\n zero-trust gateway <dns|http|network>\n cloudflare-account [inventory]\n cloudflare-account <audit|ingestion|posture>\n notifications [list] --limit\n notifications <settings|preferences|slack-channels>\n reports [list] --limit --offset\n reports show <id>\n reports settings\n intel quota\n intel <ip|asn|cidr|ja3|ja4> <query>\n settings <lifecycle-emails|broadcast-emails|applications>\n\nTime-window flags: --hours (1\u2013720, default 24) or --start/--end ISO timestamps;\noptional --asset-id. Apply to analytics (except provider-events), zero-trust,\nand cloudflare-account audit/ingestion/posture. Inventory has no window flags.\nLimits: list incidents/reports/audit 1\u2013100 (default 50); alerts/notifications\n1\u2013100 (default 100); timeline 1\u2013200 (default 100); logs 1\u20131000 (default 100);\nprovider-events 1\u2013500 (default 200); graph 1\u20131000 (default 200).\nOffsets: 0\u20131000000. Graph min-weight: 1\u20131000000 (default 1).\nAll: --json, --timeout <ms>, --customer-id <One customer UUID> (internal View-as).\nAll operations read data except confirmed incident status updates.\nUpdate status: acknowledged, mitigating, resolved, false_positive.\nSee SECURITY.md for every endpoint, supported value, alias, and pagination contract.\n";