UNPKG

@vigilbase/cli

Version:

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

403 lines (402 loc) • 25.8 kB
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";