UNPKG

@vigilbase/cli

Version:

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

401 lines (400 loc) • 18.7 kB
import { stripVTControlCharacters } from 'node:util'; import { LOCAL_ERROR_CODES } from '../local/cloudflare-checkup/errors.js'; /** A scan that ended without a result: com's copy (apps/com/lib/tools/execute-internal-scan.ts). */ export const SCAN_FAILED_MESSAGE = "The scan couldn't be completed. Try again later."; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * Terminal controls, bidi overrides, and invisible characters an agent reads but a person can't * see: zero-width characters, word joiners, the BOM, line separators and tag characters. */ const TERMINAL_UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu; /** The same, less the C0 controls JSON.stringify already escapes. */ const JSON_UNSAFE = /[\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu; /** Untrusted API data must never become terminal control sequences, bidi overrides or invisible text. */ export function sanitizeTerminal(value) { return stripVTControlCharacters(value).replace(TERMINAL_UNSAFE, ''); } /** One sanitised line, cut to `maxLength` characters with an ellipsis. */ export function singleLine(value, maxLength = 300) { const line = sanitizeTerminal(value).replace(/\s+/g, ' ').trim(); return line.length > maxLength ? `${line.slice(0, maxLength - 1).trimEnd()}…` : line; } export function createWriter(stdout = process.stdout, stderr = process.stderr) { return { stdout: (line = '') => { line = sanitizeTerminal(line); stdout.write(line.endsWith('\n') ? line : `${line}\n`); }, stderr: (line = '') => { line = sanitizeTerminal(line); stderr.write(line.endsWith('\n') ? line : `${line}\n`); }, }; } export function printJson(writer, value) { // Keep JSON lossless while preventing literal terminal/bidi controls in rendered output. writer.stdout(JSON.stringify(value, null, 2).replace(JSON_UNSAFE, (char) => [...Array(char.length).keys()].map((index) => `\\u${char.charCodeAt(index).toString(16).padStart(4, '0')}`).join(''))); } function asRecord(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; return value; } function findingFromUnknown(value) { const item = asRecord(value); if (!item) return null; const title = (typeof item.title === 'string' && item.title) || (typeof item.name === 'string' && item.name) || (typeof item.header === 'string' && item.header) || (typeof item.category === 'string' && item.category) || null; if (!title) return null; const recommendation = (typeof item.recommendation === 'string' && item.recommendation) || (typeof item.suggestion === 'string' && item.suggestion) || (Array.isArray(item.recommendations) && typeof item.recommendations[0] === 'string' ? item.recommendations[0] : null) || undefined; return { title, severity: typeof item.severity === 'string' ? item.severity : undefined, recommendation: recommendation || undefined, }; } const CHECK_FAIL_STATUSES = new Set(['fail', 'failed', 'error', 'warning', 'warn']); function pushCheckFindings(buckets, checks) { for (const raw of checks) { const item = asRecord(raw); if (!item) continue; const status = typeof item.status === 'string' ? item.status.trim().toLowerCase() : ''; if (!CHECK_FAIL_STATUSES.has(status)) continue; const title = (typeof item.name === 'string' && item.name) || (typeof item.title === 'string' && item.title) || null; if (!title) continue; const recommendation = (typeof item.recommendation === 'string' && item.recommendation) || (typeof item.description === 'string' && item.description) || (typeof item.details === 'string' && item.details) || undefined; buckets.push({ title, severity: (typeof item.severity === 'string' && item.severity) || (status === 'warning' || status === 'warn' ? 'medium' : undefined), recommendation, }); } } function summarizeCounts(result) { const details = asRecord(result.details); const summary = asRecord(result.summary) || (details ? asRecord(details.summary) : null); if (!summary) return null; const count = (n, singular, plural = `${singular}s`) => `${n} ${n === 1 ? singular : plural}`; const parts = []; if (typeof summary.failed === 'number') { parts.push(count(summary.failed, 'failed', 'failed')); } if (typeof summary.warnings === 'number') { parts.push(count(summary.warnings, 'warning')); } if (typeof summary.passed === 'number') { parts.push(count(summary.passed, 'passed', 'passed')); } if (typeof summary.missing === 'number') { parts.push(count(summary.missing, 'missing', 'missing')); } if (typeof summary.misconfigured === 'number' && summary.misconfigured > 0) { parts.push(count(summary.misconfigured, 'misconfigured', 'misconfigured')); } if (typeof summary.configured === 'number') { parts.push(count(summary.configured, 'configured', 'configured')); } if (parts.length === 0 && typeof summary.total === 'number') { parts.push(count(summary.total, 'check')); } return parts.length > 0 ? parts.join(' · ') : null; } function collectFindings(result) { const details = asRecord(result.details); const buckets = []; for (const key of ['issues', 'findings', 'recommendations']) { if (Array.isArray(result[key])) buckets.push(...result[key]); if (details && Array.isArray(details[key])) { buckets.push(...details[key]); } } // website-security / cloudflare-checkup style: details.checks[] if (Array.isArray(result.checks)) { pushCheckFindings(buckets, result.checks); } if (details && Array.isArray(details.checks)) { pushCheckFindings(buckets, details.checks); } // Prefer structured issues/checks; synthesize from records/headers only if needed. if (buckets.length === 0) { const records = details ? asRecord(details.records) : null; if (records) { for (const [name, value] of Object.entries(records)) { const record = asRecord(value); if (!record) continue; const status = typeof record.status === 'string' ? record.status : ''; if (status !== 'missing' && status !== 'misconfigured' && status !== 'fail') { continue; } buckets.push({ title: `${name.toUpperCase()} ${status === 'missing' ? 'not configured' : status}`, severity: status === 'missing' ? 'high' : 'medium', recommendation: (typeof record.recommendation === 'string' && record.recommendation) || (status === 'missing' ? `Configure ${name.toUpperCase()}` : undefined), }); } } const headers = details ? asRecord(details.headers) : null; if (headers) { for (const [name, value] of Object.entries(headers)) { const header = asRecord(value); if (!header) continue; const status = typeof header.status === 'string' ? header.status : ''; // An absent header the scanner graded good (X-XSS-Protection) is not a finding. if (status === 'good') continue; if (status !== 'missing' && status !== 'misconfigured' && header.present !== false) { continue; } const pretty = name .replace(/([A-Z])/g, '-$1') .replace(/^-/, '') .replace(/^./, (c) => c.toUpperCase()); const recs = Array.isArray(header.recommendations) ? header.recommendations.filter((item) => typeof item === 'string') : []; buckets.push({ title: (typeof header.analysis === 'string' && header.analysis) || `${pretty} ${status || 'missing'}`, severity: status === 'missing' || header.present === false ? 'high' : 'low', recommendation: recs[0] || (typeof header.recommendation === 'string' ? header.recommendation : undefined), }); } } } const findings = []; const seen = new Set(); for (const raw of buckets) { const finding = findingFromUnknown(raw); if (!finding) continue; const key = `${finding.title.toLowerCase()}|${(finding.recommendation ?? '').toLowerCase()}`; if (seen.has(key)) continue; seen.add(key); findings.push(finding); } return findings; } const MAX_FINDING_LINES = 12; /** The shareable report link, only when it is a plain https://vigilbase.com URL. */ export function reportUrl(value) { if (typeof value !== 'string') return null; let url; try { url = new URL(value); } catch { return null; } if (url.protocol !== 'https:' || url.hostname !== 'vigilbase.com' || url.port || url.username || url.password) return null; return url.href; } /** Compact human lines for a scan result (no UI/skill links, no summary dump). */ function summarizeResultCompact(result) { if (!result) return []; // Server text never starts a line of its own: each message line is indented like the rest. const lines = []; const message = typeof result.message === 'string' ? result.message : null; if (message) lines.push(...sanitizeTerminal(message).split('\n').map((line) => line.trim()).filter(Boolean)); const counts = summarizeCounts(result); if (counts) lines.push(counts); const findings = collectFindings(result); for (const finding of findings.slice(0, MAX_FINDING_LINES)) { const sev = finding.severity ? ` ${singleLine(finding.severity, 20)}` : ''; if (finding.recommendation) { lines.push(`✗${sev} ${singleLine(finding.title)} — ${singleLine(finding.recommendation)}`); } else { lines.push(`✗${sev} ${singleLine(finding.title)}`); } } if (findings.length > MAX_FINDING_LINES) { lines.push(`… +${findings.length - MAX_FINDING_LINES} more (use --json)`); } const report = reportUrl(result.shareableUrl); if (report) lines.push(`Report: ${report}`); return lines; } export function formatToolsList(catalog, mode, writer, options) { if (mode === 'json') { printJson(writer, catalog); return; } // Catalog names are the server's: a newline in one must not start a line of its own. const publicNames = catalog.tools.map((tool) => singleLine(tool.toolName, 100)).join(' '); writer.stdout(`public ${publicNames || '(none)'}`); const authRequired = options?.authRequiredTools ?? options?.sensitiveTools ?? []; if (authRequired.length) { writer.stdout(`auth-req ${authRequired.map((name) => singleLine(name, 100)).join(' ')}`); } if (typeof options?.isAuthenticated === 'boolean') { writer.stdout(options.isAuthenticated ? 'auth signed in' : 'auth signed out'); } } export function formatToolDescription(tool, mode, writer, skillMarkdown) { if (mode === 'json') { printJson(writer, { success: true, ...tool, skillMarkdown: skillMarkdown ?? null }); return; } // Catalog text is the server's: each field stays on its own line. writer.stdout(`${singleLine(tool.toolName, 100)} ${singleLine(tool.name)}`); writer.stdout(singleLine(tool.description, 2000)); if (tool.targetDescription) writer.stdout(`Target: ${singleLine(tool.targetDescription)}`); writer.stdout(`vigilbase run ${singleLine(tool.toolName, 100)} <target>`); if (skillMarkdown) { writer.stdout(); writer.stdout(skillMarkdown.trimEnd()); } } /** * Why a scan failed, or null when it has a result. A scan the scanner could not grade has * `status: "error"` with a message and no grade: One and vigilbase.com both send it as a 422, * which the clients turn into a `scan_unassessed` result. */ function scanFailure(result) { if (result.success && result.result?.status !== 'error') return null; const message = result.result?.status === 'error' ? result.result.message : result.success ? undefined : typeof result.error === 'string' ? result.error : result.error?.message; // Vigilbase's scanner wrote it, but it is bounded like any server reason. return (typeof message === 'string' && singleLine(message)) || SCAN_FAILED_MESSAGE; } /** Whether a scan produced a result; the exit code follows it. */ export function isSuccessfulScan(result) { return scanFailure(result) === null; } export function formatExecuteResult(result, mode, writer) { const failure = scanFailure(result); if (mode === 'json') { // A scan without a result fails in JSON as in the exit code. // A failed scan has no grade, whatever placeholder the scanner used. printJson(writer, failure === null ? result : { ...result, success: false, error: failure, grade: null, gradeScore: null }); return; } const tool = singleLine(result.toolName ?? 'tool', 100); const target = singleLine(result.target ?? '', 2100); const gradeLetter = result.grade != null ? singleLine(String(result.grade), 10) : ''; const grade = failure === null && result.grade != null ? `${gradeLetter}${result.gradeScore != null ? ` ${singleLine(String(result.gradeScore), 10)}` : ''}` : 'n/a'; if (failure !== null) { writer.stderr(`✗ ${tool} · ${target} · ${grade}`); writer.stderr(` ${failure}`); return; } // A graded scan exits 0 whatever the grade; only a secure one without an F gets the ✓. const isClean = result.result?.status === 'secure' && gradeLetter.toUpperCase() !== 'F'; writer.stdout(`${isClean ? '✓' : '•'} ${tool} · ${target} · ${grade}`); for (const line of summarizeResultCompact(result.result)) { writer.stdout(` ${line}`); } } /** Issues of a ZodError, also one from another copy of zod. */ function zodErrorIssues(error) { if (!(error instanceof Error) || error.name !== 'ZodError' || !('issues' in error) || !Array.isArray(error.issues)) return null; return error.issues; } export function formatError(error, mode, writer) { let message = error instanceof Error ? error.message : String(error); let reason = ''; const zodIssues = zodErrorIssues(error); // A ZodError's message is its serialised issue list; show the issue messages instead. if (zodIssues) message = zodIssues.map((issue) => `${issue.path?.length ? `${issue.path.map(String).join('.')}: ` : ''}${singleLine(String(issue.message))}`).join(' ') || 'Invalid argument.'; // Server bodies can contain credentials, customer data, or arbitrary error text, so an error that // carries one shows only text the CLI wrote, or the reason the public client accepted. if (error && typeof error === 'object' && 'body' in error && error.body != null && 'status' in error && typeof error.status === 'number') { if ('code' in error && error.code === 'FLAREHAWK_VIEW_AS_REQUIRED') { message = 'Select a customer with --customer-id <customer UUID> for security commands.'; } else { // Only the reason the public client accepted from a known problem+json error is shown. reason = 'reason' in error && typeof error.reason === 'string' ? singleLine(error.reason) : ''; const isCliAuthored = 'isCliAuthored' in error && error.isCliAuthored === true; message = reason || (isCliAuthored ? message : `Request failed (HTTP ${error.status}).`); } } const payload = { success: false, error: message, }; if (zodIssues) payload.code = 'INVALID_ARGUMENT'; if (error && typeof error === 'object') { if ('status' in error && typeof error.status === 'number' && Number.isInteger(error.status) && error.status >= 0 && error.status <= 599) { payload.status = error.status; } if ('code' in error && typeof error.code === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,79}$/.test(error.code)) { payload.code = error.code; } if ('retryAfterSec' in error && typeof error.retryAfterSec === 'number' && Number.isSafeInteger(error.retryAfterSec) && error.retryAfterSec >= 0) { payload.retryAfterSec = error.retryAfterSec; } // CLI-authored remediation (never server text); bounded and sanitised like any output. if ('hint' in error && typeof error.hint === 'string') { const hint = singleLine(error.hint); if (hint) payload.hint = hint; } // The One tool run a failed scan recorded, for `vigilbase runs get`. if ('toolRunId' in error && typeof error.toolRunId === 'string' && UUID.test(error.toolRunId)) { payload.toolRunId = error.toolRunId; } } if (mode === 'json') { printJson(writer, payload); return; } // Local checkup codes show as in the terminal UI; invalid-argument messages already name the flag. const isLocalCode = LOCAL_ERROR_CODES.includes(payload.code); writer.stderr(`Error: ${message}${(reason || isLocalCode) && typeof payload.code === 'string' ? ` (${payload.code})` : ''}`); if (typeof payload.retryAfterSec === 'number') { // Quotas reset after up to an hour: minutes read better than thousands of seconds. const seconds = payload.retryAfterSec; writer.stderr(seconds < 120 ? `Retry after ${seconds}s` : `Retry after about ${Math.round(seconds / 60)} minutes`); } if (typeof payload.hint === 'string') { writer.stderr(`Hint: ${payload.hint}`); } }