UNPKG

@vigilbase/cli

Version:

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

360 lines (359 loc) • 16.1 kB
import { managedAccessToken } from '../auth/refresh.js'; import { readCredentials } from '../auth/store.js'; import { identityProvider } from '../auth/provider.js'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { validateOneOrigin } from './config.js'; import { validateStripePortalUrl } from './open-url.js'; import { parseJsonResponse, requestText } from './network.js'; import { oneErrorHint } from './one-error-hints.js'; import { SCAN_FAILED_MESSAGE } from './format.js'; import { retryAfterSeconds, VigilbaseApiError } from './client.js'; /** * Tools that require Vigilbase One authentication (auth-required scanners). * Keep preference order aligned with `@vigilbase/tools` catalog * (`requiresAuthentication: true` entries in VIGILBASE_TOOLS). * Public agent tools remain available without login via vigilbase.com. */ export const ONE_AUTH_REQUIRED_TOOLS = [ 'website-security', 'react2shell', 'fortibleed-check', ]; export function isOneAuthRequiredTool(toolName) { return ONE_AUTH_REQUIRED_TOOLS.includes(toolName); } /** Static catalog entries for auth-required tools (not listed on the public agent API). */ export const ONE_AUTH_REQUIRED_TOOL_DEFINITIONS = { 'website-security': { toolName: 'website-security', name: 'Website Security Scanner', description: 'Intrusive website posture scan (requires Vigilbase One login).', targetDescription: 'Domain or URL to scan.', uiUrl: '/dashboard/tools/website-security', skillUrl: 'https://vigilbase.com/agent-skills/website-security.md', executeUrl: '/api/tools/website-security/execute', }, react2shell: { toolName: 'react2shell', name: 'React2Shell Probe', description: 'React Server Components RCE probe (requires Vigilbase One login).', targetDescription: 'URL of a React Server Components app.', uiUrl: '/dashboard/tools/react2shell', skillUrl: 'https://vigilbase.com/agent-skills/react2shell.md', executeUrl: '/api/tools/react2shell/execute', }, 'fortibleed-check': { toolName: 'fortibleed-check', name: 'FortiBleed Check', description: 'FortiBleed domain dataset lookup (requires Vigilbase One login).', targetDescription: 'Domain to look up.', uiUrl: '/dashboard/tools/fortibleed-check', skillUrl: 'https://vigilbase.com/agent-skills/fortibleed-check.md', executeUrl: '/api/tools/fortibleed-check/execute', }, }; export function resolveToolDefinition(toolName, publicTools, oneBaseUrl) { const fromPublic = publicTools.find((tool) => tool.toolName === toolName); if (fromPublic) return fromPublic; if (!isOneAuthRequiredTool(toolName)) return null; const authRequired = ONE_AUTH_REQUIRED_TOOL_DEFINITIONS[toolName]; const origin = oneBaseUrl.replace(/\/+$/, ''); return { ...authRequired, uiUrl: authRequired.uiUrl.startsWith('http') ? authRequired.uiUrl : `${origin}${authRequired.uiUrl}`, executeUrl: authRequired.executeUrl.startsWith('http') ? authRequired.executeUrl : `${origin}${authRequired.executeUrl}`, }; } /** Tools executable through One's authenticated `/api/tools/{tool}/execute` bridge. */ export const ONE_EXECUTABLE_TOOLS = [ 'security-headers', 'email-security', 'website-security', 'react2shell', 'fortibleed-check', ]; export function isOneExecutableTool(toolName) { return ONE_EXECUTABLE_TOOLS.includes(toolName); } export class VigilbaseOneClient { config; accessToken; managed; /** The userinfo endpoint Identity discovery named for the last getSession, if it used Identity. */ userinfoEndpoint; constructor(config, accessToken, managed = false) { this.config = config; this.accessToken = accessToken; this.managed = managed; } async request(path, init) { const url = `${validateOneOrigin(this.config.oneBaseUrl)}${path.startsWith('/') ? path : `/${path}`}`; if (this.managed) { const token = await managedAccessToken(this.config); if (token) this.accessToken = token; } const send = () => requestText(this.config, url, { ...init, headers: { Accept: 'application/json', ...(this.accessToken ? { Authorization: `Bearer ${this.accessToken}` } : {}), 'User-Agent': this.config.userAgent, ...(init?.headers ?? {}), }, }); let { response, text } = await send(); if (response.status === 401 && this.managed) { const token = await managedAccessToken(this.config, this.accessToken); if (token) { this.accessToken = token; ({ response, text } = await send()); } } return { response, body: parseJsonResponse(response, text) }; } /** Authenticated JSON transport shared by the One command clients. */ async requestJson(path, init, options) { const { response, body } = await this.request(path, init); const parsed = (body ?? {}); const errorBody = z.object({ error: z.string().optional(), message: z.string().optional(), code: z.string().optional(), }).passthrough().safeParse(body); const errorDetails = errorBody.success ? errorBody.data : {}; let message; if (errorDetails.code === 'FLAREHAWK_VIEW_AS_REQUIRED') { message = 'Select a customer with --customer-id <customer UUID> for security commands.'; } const hint = response.ok ? undefined : oneErrorHint({ method: init?.method ?? 'GET', path, status: response.status, code: errorDetails.code, hasCustomerScope: options?.hasCustomerScope, }); // Every message below is the CLI's own; One's error text stays in the body, which is never printed. if (response.status === 401 || response.status === 403) { throw new VigilbaseApiError(message || (response.status === 401 ? 'Authentication required. Run `vigilbase login`.' // Signed in, but One refuses this user or this request: logging in again won't help. : "You don't have access to this."), { status: response.status, body: parsed, code: errorDetails.code, hint, isCliAuthored: true }); } if (response.status === 429) { throw new VigilbaseApiError(message || 'Rate limit exceeded.', { status: 429, body: parsed, retryAfterSec: retryAfterSeconds(response, body), code: errorDetails.code, isCliAuthored: true, }); } const allowed = options?.allowStatuses ?? []; if (!response.ok && !allowed.includes(response.status)) { throw new VigilbaseApiError(message || `Request failed (HTTP ${response.status}).`, { status: response.status, body: parsed, code: errorDetails.code, hint, isCliAuthored: true }); } if (!z.record(z.string(), z.unknown()).safeParse(body).success) { throw new VigilbaseApiError('Invalid One API response. Expected a JSON object.', { status: response.status }); } if (parsed.success === false || ('ok' in parsed && parsed.ok === false)) { throw new VigilbaseApiError('One API reported an unsuccessful operation.', { status: response.status, body: parsed, code: errorDetails.code, isCliAuthored: true, }); } return parsed; } async getSession() { const saved = this.managed ? await readCredentials(this.config) : null; const issuer = this.config.authProvider?.issuer ?? (saved?.version === 2 ? saved.one?.issuer : undefined); if (issuer) { const provider = await identityProvider(this.config, issuer); this.userinfoEndpoint = provider.userinfoEndpoint; if (this.managed) { const token = await managedAccessToken(this.config); if (!token) throw new VigilbaseApiError('Authentication required.', { status: 401 }); this.accessToken = token; } const load = () => requestText(this.config, provider.userinfoEndpoint, { headers: { Authorization: `Bearer ${this.accessToken}`, Accept: 'application/json', 'User-Agent': this.config.userAgent } }); let { response, text } = await load(); if (response.status === 401 && this.managed) { const token = await managedAccessToken(this.config, this.accessToken); if (token) { this.accessToken = token; ({ response, text } = await load()); } } if (!response.ok) throw new VigilbaseApiError('Failed to load Identity profile.', { status: response.status }); const user = z.object({ sub: z.string(), email: z.string().optional(), name: z.string().optional() }).safeParse(parseJsonResponse(response, text)); if (!user.success) throw new VigilbaseApiError('Invalid Identity profile.', { status: response.status }); return { user: { id: user.data.sub, email: user.data.email, name: user.data.name } }; } const { response, body } = await this.request('/api/auth/get-session'); if (!response.ok) { throw new VigilbaseApiError(`Failed to load One session (HTTP ${response.status})`, { status: response.status, body, isCliAuthored: true }); } const schema = z.object({ session: z.record(z.string(), z.unknown()).nullable().optional(), user: z.object({ id: z.string().optional(), email: z.string().nullable().optional(), name: z.string().nullable().optional(), }).passthrough().nullable().optional(), }).refine((value) => 'session' in value || 'user' in value).nullable(); const result = schema.safeParse(body); if (!result.success) { throw new VigilbaseApiError('Invalid One session response. Check the One API URL.', { status: response.status }); } return result.data ?? {}; } async getAccount() { return this.requestJson('/api/account'); } async getAccountServices() { return this.requestJson('/api/account/services'); } async getAccountOrganization() { return this.requestJson('/api/account/organization'); } async getAccountEndpoints() { return this.requestJson('/api/account/endpoints'); } async getAccountSessions() { return this.requestJson('/api/account/sessions'); } async getAccountInvitations() { return this.requestJson('/api/auth/orgs/invitations'); } async createAccountInvitation(input) { return this.requestJson('/api/auth/orgs/invite', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...input, operationId: randomUUID() }), }); } async getAccountBilling() { return this.requestJson('/api/account/billing'); } async createBillingPortal() { const payload = await this.requestJson('/api/billing/portal', { method: 'POST', }); return { success: Boolean(payload.success), url: validateStripePortalUrl(payload.data?.url ?? payload.url), }; } async getWatchdogTargets() { return this.requestJson('/api/account/watchdog/targets'); } async getWatchdogScans(options) { const params = new URLSearchParams(); if (options?.targetId) params.set('targetId', options.targetId); if (typeof options?.limit === 'number') params.set('limit', String(options.limit)); const query = params.toString(); return this.requestJson(`/api/account/watchdog/scans${query ? `?${query}` : ''}`); } async getCloudflareZones() { return this.requestJson('/api/account/cloudflare/zones'); } async getCloudflareCheckups(options) { const params = new URLSearchParams(); if (options?.accountId) params.set('accountId', options.accountId); const query = params.toString(); return this.requestJson(`/api/services/cloudflare/checkups${query ? `?${query}` : ''}`); } async startCloudflareCheckup(options) { return this.requestJson('/api/services/cloudflare/checkups/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountId: options?.accountId, }), }); } async listToolRuns(options) { const params = new URLSearchParams(); if (options?.toolName) params.set('toolName', options.toolName); if (typeof options?.limit === 'number') params.set('limit', String(options.limit)); const query = params.toString(); return this.requestJson(`/api/tool-runs${query ? `?${query}` : ''}`); } async getToolRun(id) { return this.requestJson(`/api/tool-runs/${encodeURIComponent(id)}`); } async executeTool(toolName, target) { try { return await this.requestJson(`/api/tools/${encodeURIComponent(toolName)}/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target }), }, { allowStatuses: [422] }); } catch (error) { const unassessed = unassessedScan(error); if (unassessed) return unassessed; throw failedScanError(error) ?? error; } } } /** One's answer when the scan itself failed, for example when com ran out of memory. */ const failedScanSchema = z.object({ success: z.literal(false), data: z.object({ status: z.literal('failed'), toolRunId: z.unknown().optional() }).passthrough(), }).passthrough(); const unassessedScanSchema = z.object({ code: z.literal('scan_unassessed'), data: z.object({ toolRunId: z.unknown().optional(), result: z.object({ status: z.literal('error'), message: z.string() }).passthrough(), }), }); /** * A scan the scanner ran but could not assess (a bot verification challenge, an * error page). The result carries the scanner's message, which the formatter * prints bounded like any scanner result; One's error text stays unused. */ function unassessedScan(error) { if (!(error instanceof VigilbaseApiError) || error.status !== 422) return undefined; const unassessed = unassessedScanSchema.safeParse(error.body); if (!unassessed.success) return undefined; const toolRunId = z.string().uuid().safeParse(unassessed.data.data.toolRunId); return { success: false, code: 'scan_unassessed', data: { toolRunId: toolRunId.success ? toolRunId.data : undefined, status: 'failed', grade: null, gradeScore: null, result: unassessed.data.data.result, }, }; } /** com's copy for a 422 failed scan, with the run One recorded; One's error text stays hidden. */ function failedScanError(error) { if (!(error instanceof VigilbaseApiError) || error.status !== 422) return undefined; const failed = failedScanSchema.safeParse(error.body); if (!failed.success) return undefined; const toolRunId = z.string().uuid().safeParse(failed.data.data.toolRunId); return new VigilbaseApiError(SCAN_FAILED_MESSAGE, { status: 422, body: error.body, code: error.code, isCliAuthored: true, toolRunId: toolRunId.success ? toolRunId.data : undefined, }); }