UNPKG

@vigilbase/cli

Version:

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

273 lines (272 loc) • 12.8 kB
import { z } from 'zod'; import { toolNameSchema, validateArguments } from './legacy-validation.js'; import { NetworkError } from '../lib/network.js'; import { PUBLIC_TOOLS_PATH, VigilbaseApiError } from '../lib/client.js'; import { getOneAccessToken, getOptionalOneAccessToken, hasUsableSignIn, readCredentials } from '../auth/index.js'; import { SignInUnsupportedError } from '../auth/store.js'; import { formatExecuteResult, formatToolDescription, formatToolsList, isSuccessfulScan, } from '../lib/format.js'; import { isOneAuthRequiredTool, isOneExecutableTool, ONE_AUTH_REQUIRED_TOOLS, resolveToolDefinition, VigilbaseOneClient, } from '../lib/one-client.js'; import { EXIT, ok, outputJson, withSpinner, } from './helpers.js'; const ONE_SIGN_IN_REQUIRED = 'This tool needs a Vigilbase One sign-in: run `vigilbase login`.'; /** * For --via one and auth-required tools only: any sign-in problem fails. Specific errors keep their * message; those that don't already point to `vigilbase login` get it as their hint. */ async function requireOneSignIn(config) { let accessToken; try { accessToken = await getOneAccessToken(config); } catch (error) { // Windows cannot sign in at all, so pointing to login would mislead. if (error instanceof Error && !(error instanceof SignInUnsupportedError) && !error.message.includes('`vigilbase login`')) { Object.assign(error, { hint: ONE_SIGN_IN_REQUIRED }); } throw error; } if (!accessToken) throw new Error(ONE_SIGN_IN_REQUIRED); return accessToken; } /** Public tools work without login. Auth-required tools need Vigilbase One. */ export const handleList = async (ctx) => { validateArguments(ctx, 0); const catalog = await withSpinner(ctx, 'Loading tool catalog', () => ctx.publicClient.listTools()); // Offline: list never refreshes, so it cannot leave a refresh half-done. const authenticated = await hasUsableSignIn(ctx.config, ctx.writer.stderr); if (ctx.mode === 'json') { outputJson(ctx, { ...catalog, authenticated, authRequiredTools: [...ONE_AUTH_REQUIRED_TOOLS], oneExecutableTools: authenticated ? [ ...new Set([ ...catalog.tools.map((tool) => tool.toolName), ...ONE_AUTH_REQUIRED_TOOLS, ]), ] : catalog.tools.map((tool) => tool.toolName), }); } else { formatToolsList(catalog, ctx.mode, ctx.writer, { authRequiredTools: ONE_AUTH_REQUIRED_TOOLS, isAuthenticated: authenticated, }); } return ok(); }; export const handleRun = async (ctx) => { const usage = ctx.command === 'headers' ? 'vigilbase headers <url>' : ctx.command === 'email' ? 'vigilbase email <domain>' : 'vigilbase run <tool> <target>'; validateArguments(ctx, ctx.command === 'run' ? 2 : 1, ['via', 'isPublic'], usage); let toolName; let target; if (ctx.command === 'headers') { toolName = 'security-headers'; target = ctx.rest[0] ?? ''; } else if (ctx.command === 'email') { toolName = 'email-security'; target = ctx.rest[0] ?? ''; } else { toolName = ctx.rest[0] ?? ''; target = ctx.rest[1] ?? ''; } if (!toolName || !target) throw new Error(`Usage: ${usage}`); toolName = toolNameSchema.parse(toolName); target = z.string().trim().min(1).max(2048).refine((value) => !/[\u0000-\u001f\u007f]/.test(value), 'Target must not contain control characters.').parse(target); const via = ctx.values.via; if (ctx.values.isPublic && via === 'one') { throw new Error('--public cannot be combined with --via one.'); } if (via && via !== 'one' && via !== 'public') { throw new Error('--via must be one of: public, one'); } const forcePublic = Boolean(ctx.values.isPublic) || via === 'public'; const forceOne = via === 'one'; // Public tools stay free unless forced onto One. Auth-required tools need login. // The public route is anonymous and never reads the credential store. const useOne = !forcePublic && (forceOne || isOneAuthRequiredTool(toolName)); if (useOne) { const accessToken = await requireOneSignIn(ctx.config); if (!isOneExecutableTool(toolName)) { throw new Error(`Tool "${toolName}" is not executable via Vigilbase One CLI yet.`); } const oneClient = new VigilbaseOneClient(ctx.config, accessToken, true); const oneResult = await withSpinner(ctx, `Running ${toolName} against ${target}`, () => oneClient.executeTool(toolName, target)); const unassessed = oneResult.code === 'scan_unassessed'; const toolRunId = oneResult.data?.toolRunId; const result = { success: oneResult.success === true, toolName, target, grade: oneResult.data?.grade ?? null, gradeScore: oneResult.data?.gradeScore ?? null, // Only a completed scan, or one the scanner could not assess, carries the scanner's result. result: oneResult.success === true || unassessed ? oneResult.data?.result ?? null : null, // One's error text is server text; a failed scan prints the CLI's own message. error: null, uiUrl: `${ctx.config.oneBaseUrl}/dashboard/tools/${toolName}`, ...(unassessed ? { status: 422, code: 'scan_unassessed', ...(toolRunId ? { toolRunId } : {}) } : {}), }; formatExecuteResult(result, ctx.mode, ctx.writer); return isSuccessfulScan(result) ? ok() : EXIT.ERROR; } const result = await withSpinner(ctx, `Running ${toolName} against ${target}`, () => ctx.publicClient.executeTool(toolName, target)); formatExecuteResult(result, ctx.mode, ctx.writer); return isSuccessfulScan(result) ? ok() : EXIT.ERROR; }; export const handleDescribe = async (ctx) => { validateArguments(ctx, 1, ['isNoSkill'], 'vigilbase describe <tool>'); toolNameSchema.parse(ctx.rest[0]); const toolName = ctx.rest[0]; if (!toolName) throw new Error('Usage: vigilbase describe <tool>'); const catalog = await withSpinner(ctx, 'Loading tool catalog', () => ctx.publicClient.listTools()); const tool = resolveToolDefinition(toolName, catalog.tools, ctx.config.oneBaseUrl); if (!tool) { throw new Error(`Unknown tool "${toolName}". Run \`vigilbase list\` for the allowlist.`); } let skillMarkdown; if (!ctx.values.isNoSkill) { try { skillMarkdown = await withSpinner(ctx, 'Fetching skill docs', () => ctx.publicClient.fetchSkillMarkdown(tool.skillUrl)); } catch { // Auth-required tools may not publish public skill docs yet. } } formatToolDescription(tool, ctx.mode, ctx.writer, skillMarkdown); return ok(); }; export const handleSkill = async (ctx) => { validateArguments(ctx, 1, [], 'vigilbase skill <tool>'); toolNameSchema.parse(ctx.rest[0]); const toolName = ctx.rest[0]; if (!toolName) throw new Error('Usage: vigilbase skill <tool>'); const catalog = await withSpinner(ctx, 'Loading tool catalog', () => ctx.publicClient.listTools()); const definition = resolveToolDefinition(toolName, catalog.tools, ctx.config.oneBaseUrl); if (!definition) { throw new Error(`Unknown tool "${toolName}". Run \`vigilbase list\` for the allowlist.`); } const skillMarkdown = await withSpinner(ctx, 'Fetching skill docs', () => ctx.publicClient.fetchSkillMarkdown(definition.skillUrl)); if (ctx.mode === 'json') { outputJson(ctx, { toolName: definition.toolName, skillUrl: definition.skillUrl, content: skillMarkdown, }); } else { ctx.writer.stdout(skillMarkdown.trimEnd()); } return ok(); }; export const handleBadge = async (ctx) => { validateArguments(ctx, 1, ['style'], 'vigilbase badge <domain>'); const domain = ctx.rest[0]; if (!domain) throw new Error('Usage: vigilbase badge <domain>'); const styleRaw = ctx.values.style ?? 'default'; if (styleRaw !== 'default' && styleRaw !== 'compact' && styleRaw !== 'mini') { throw new Error('--style must be one of: default, compact, mini'); } const url = ctx.publicClient.badgeUrl(domain, styleRaw); if (ctx.mode === 'json') { outputJson(ctx, { domain, style: styleRaw, url }); } else { ctx.writer.stdout(url); } return ok(); }; export const handleDoctor = async (ctx) => { validateArguments(ctx, 0); let oneCheckUrl = `${ctx.config.oneBaseUrl}/api/auth/get-session`; // A sign-in problem fails its own check, and One connectivity is then checked anonymously. // The saved sign-in may refresh, with the configured timeout like any One command. const signIn = getOptionalOneAccessToken(ctx.config); const [publicResult, oneResult, signInResult] = await withSpinner(ctx, 'Checking public API and Vigilbase One', () => Promise.allSettled([ ctx.publicClient.listTools(), (async () => { const { accessToken } = await signIn; const saved = accessToken ? await readCredentials(ctx.config) : null; // An Identity session is checked at the userinfo endpoint its issuer's discovery names. if (saved?.version === 2) oneCheckUrl = `${saved.one.issuer}/.well-known/openid-configuration`; // Signed out, the check stays anonymous and never reads the credential store. const client = new VigilbaseOneClient(ctx.config, accessToken ?? '', Boolean(accessToken)); try { const session = await client.getSession(); return { authenticated: Boolean(session.user?.id) }; } catch (error) { // A signed-out session is a valid connectivity result. if (error instanceof VigilbaseApiError && error.status === 401) { return { authenticated: false }; } throw error; } finally { if (client.userinfoEndpoint) oneCheckUrl = client.userinfoEndpoint; } })(), signIn.then(({ problem }) => { if (problem) throw new Error(problem); }), ])); const checkResult = (result, url) => { if (result.status === 'fulfilled') return { ok: true, ...(url ? { url } : {}) }; const error = result.reason; return { ok: false, ...(url ? { url } : {}), error: error instanceof Error ? error.message : 'Connectivity check failed.', ...(error instanceof VigilbaseApiError || error instanceof NetworkError ? { status: error.status } : {}), ...(error instanceof NetworkError ? { code: error.code } : {}), }; }; const checks = { publicApi: checkResult(publicResult, `${ctx.config.publicBaseUrl}${PUBLIC_TOOLS_PATH}`), one: checkResult(oneResult, oneCheckUrl), signIn: checkResult(signInResult), }; const isOk = checks.publicApi.ok && checks.one.ok && checks.signIn.ok; const firstFailure = Object.values(checks).find((check) => !check.ok); const catalog = publicResult.status === 'fulfilled' ? publicResult.value : null; const payload = { ok: isOk, success: isOk, ...(firstFailure ? { error: firstFailure.error, status: firstFailure.status } : {}), publicBaseUrl: ctx.config.publicBaseUrl, oneBaseUrl: ctx.config.oneBaseUrl, clientId: ctx.config.clientId, authenticated: oneResult.status === 'fulfilled' && oneResult.value.authenticated, toolCount: catalog?.tools.length ?? 0, tools: catalog?.tools.map((tool) => tool.toolName) ?? [], authRequiredTools: [...ONE_AUTH_REQUIRED_TOOLS], openapi: catalog?.openapi ?? null, skillsIndex: catalog?.skillsIndex ?? null, rateLimit: catalog?.rateLimit ?? null, checks, }; if (ctx.mode === 'json') { outputJson(ctx, payload); } else { for (const [label, check] of Object.entries(checks)) { ctx.writer.stdout(`${check.ok ? 'ok' : 'fail'} ${label}${check.url ? ` ${check.url}` : ''}${check.error ? ` — ${check.error}` : ''}`); } ctx.writer.stdout(`auth=${payload.authenticated ? 'in' : 'out'} tools=${payload.tools.join(',') || 'unavailable'}`); } return isOk ? ok() : EXIT.ERROR; };