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