UNPKG

@vigilbase/cli

Version:

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

350 lines (349 loc) • 15.4 kB
import { stripVTControlCharacters } from 'node:util'; /** 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; /** Untrusted API data must never become terminal control sequences or bidi overrides. */ export function sanitizeTerminal(value) { return stripVTControlCharacters(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ''); } /** 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(/[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)); } 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 : ''; 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; /** Compact human lines for a scan result (no UI/skill links, no summary dump). */ function summarizeResultCompact(result) { if (!result) return []; const lines = []; const message = typeof result.message === 'string' ? result.message : null; if (message) lines.push(message); 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 ? ` ${finding.severity}` : ''; if (finding.recommendation) { lines.push(`✗${sev} ${finding.title} — ${finding.recommendation}`); } else { lines.push(`✗${sev} ${finding.title}`); } } if (findings.length > MAX_FINDING_LINES) { lines.push(`… +${findings.length - MAX_FINDING_LINES} more (use --json)`); } return lines; } export function formatToolsList(catalog, mode, writer, options) { if (mode === 'json') { printJson(writer, catalog); return; } const publicNames = catalog.tools.map((tool) => tool.toolName).join(' '); writer.stdout(`public ${publicNames || '(none)'}`); const authRequired = options?.authRequiredTools ?? options?.sensitiveTools ?? []; if (authRequired.length) { writer.stdout(`auth-req ${authRequired.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, { ...tool, skillMarkdown: skillMarkdown ?? null }); return; } writer.stdout(`${tool.toolName} ${tool.name}`); const blurb = tool.description.length > 100 ? `${tool.description.slice(0, 97).trimEnd()}…` : tool.description; writer.stdout(blurb); writer.stdout(`vigilbase run ${tool.toolName} <target>`); if (skillMarkdown) { writer.stdout(); writer.stdout(skillMarkdown.trimEnd()); } } /** * Why a scan failed, or null when it has a result. A scan com could not grade comes back * inside a successful response as `status: "error"` with a message and no grade. */ 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 = result.toolName ?? 'tool'; const target = result.target ?? ''; const grade = failure === null && result.grade != null ? `${result.grade}${result.gradeScore != null ? ` ${result.gradeScore}` : ''}` : 'n/a'; if (failure !== null) { writer.stderr(`✗ ${tool} · ${target} · ${grade}`); writer.stderr(` ${failure}`); return; } writer.stdout(`✓ ${tool} · ${target} · ${grade}`); for (const line of summarizeResultCompact(result.result)) { writer.stdout(` ${line}`); } } export function formatError(error, mode, writer) { let message = error instanceof Error ? error.message : String(error); let reason = ''; // 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 (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; } writer.stderr(`Error: ${message}${reason && typeof payload.code === 'string' ? ` (${payload.code})` : ''}`); if (typeof payload.retryAfterSec === 'number') { writer.stderr(`Retry after ${payload.retryAfterSec}s`); } if (typeof payload.hint === 'string') { writer.stderr(`Hint: ${payload.hint}`); } }