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