@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
504 lines (503 loc) • 25.6 kB
JavaScript
/**
* A readable Cloudflare Checkup result, from what the server sent: the
* locked free projection a local checkup's upload returns, or the stored
* result One returns for `cf checkup result` and `cf checkup`.
*
* Presentation only. Grades, scores, severities, statuses, counts and names
* are the server's; nothing here computes a grade or assigns a severity. It
* groups the server's checks by check ID, orders them by the server's status
* and severity, and fits them to the terminal.
*
* Every string from the payload is sanitised when it is read (no terminal
* controls, bidi overrides or line breaks), so the views only ever hold text
* that is safe to print; styles are added by the renderers.
*/
import { CLI_CHECKUP_LIMITS } from './checkup-report-limits.js';
import { reportUrl, sanitizeTerminal } from './format.js';
import { displayWidth, padEnd, truncate } from './tui.js';
/** Longest text kept from the payload; the renderers cut to the terminal width. */
const MAX_TEXT = 300;
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : null;
const list = (value) => Array.isArray(value) ? value.map(record).filter((item) => item !== null) : [];
/** Untrusted text on one line: controls, bidi overrides and line breaks removed, whitespace collapsed. */
function text(value, max = MAX_TEXT) {
if (typeof value !== 'string')
return null;
const flat = sanitizeTerminal(value.replace(/[\s\u0085\u2028\u2029]+/g, ' ')).trim();
return flat === '' ? null : [...flat].slice(0, max).join('');
}
const count = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
const score = (value) => typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
/** A valid timestamp, normalised to ISO 8601 UTC. */
function isoDate(value) {
if (typeof value !== 'string')
return null;
const time = Date.parse(value);
return Number.isNaN(time) ? null : new Date(time).toISOString();
}
/** The first non-empty line of untrusted text. */
function firstLine(value) {
if (typeof value !== 'string')
return null;
for (const line of value.split(/\r\n|[\n\r\u0085\u2028\u2029]/)) {
const flat = text(line);
if (flat)
return flat;
}
return null;
}
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'warning', 'low', 'info'];
const severityRank = (severity) => {
const rank = SEVERITY_ORDER.indexOf((severity ?? '').toLowerCase());
return rank === -1 ? SEVERITY_ORDER.length : rank;
};
const statusRank = (status) => (status === 'failed' ? 0 : status === 'warning' ? 1 : 2);
function counts(value) {
const summary = record(value);
if (!summary)
return null;
const view = { total: count(summary.total), passed: count(summary.passed), warnings: count(summary.warnings), failed: count(summary.failed), skipped: count(summary.skipped) };
return view.passed === null && view.warnings === null && view.failed === null ? null : view;
}
/** Instances of the same check ID become one finding; the most severe of the server's labels is shown. */
function group(instances) {
const groups = new Map();
for (const instance of instances) {
let entry = groups.get(instance.key);
if (!entry) {
entry = { finding: { name: instance.name, severity: instance.severity, status: instance.status, zones: 0, account: false, recommendation: null }, zones: new Set() };
groups.set(instance.key, entry);
}
const { finding } = entry;
if (statusRank(instance.status) < statusRank(finding.status))
finding.status = instance.status;
if (severityRank(instance.severity) < severityRank(finding.severity))
finding.severity = instance.severity;
if (instance.zone !== null)
entry.zones.add(instance.zone);
if (instance.account)
finding.account = true;
finding.recommendation ??= instance.recommendation;
}
const findings = [...groups.values()].map(({ finding, zones }) => ({ ...finding, zones: zones.size }));
return findings
.map((finding, index) => ({ finding, index }))
.sort((a, b) => statusRank(a.finding.status) - statusRank(b.finding.status)
|| severityRank(a.finding.severity) - severityRank(b.finding.severity)
|| Number(b.finding.account) - Number(a.finding.account)
|| b.finding.zones - a.finding.zones
|| a.index - b.index)
.map(({ finding }) => finding);
}
function checkInstances(checks) {
const instances = [];
for (const check of checks) {
const status = check.status === 'fail' || check.status === 'error' ? 'failed' : check.status === 'warning' ? 'warning' : null;
if (status === null)
continue;
const name = text(check.name, 120) ?? text(check.id, 120);
if (name === null)
continue;
const zone = text(check.zoneName) ?? text(check.zoneId);
instances.push({
key: typeof check.id === 'string' && check.id !== '' ? `id:${check.id}` : `name:${name}`,
name,
severity: text(check.severity, 12),
status,
zone,
account: check.scope === 'account' || zone === null,
recommendation: firstLine(check.recommendation),
});
}
return instances;
}
/** A legacy One report's issues (no status; severity `critical`, `warning` or `info`). */
function issueInstances(issues) {
const instances = [];
for (const issue of issues) {
const name = text(issue.title, 120);
if (name === null)
continue;
const zone = text(issue.zoneName) ?? text(issue.zoneId);
instances.push({
// Legacy issue IDs may carry the zone, so the same issue is grouped by its title.
key: `name:${name}`,
name,
severity: text(issue.severity, 12),
status: null,
zone,
account: zone === null,
recommendation: firstLine(issue.recommendation),
});
}
return instances;
}
function zoneRows(zones) {
const rows = [];
for (const zone of zones) {
const name = text(zone.name, 120) ?? text(zone.zoneName, 120);
if (name === null)
continue;
const summary = record(zone.summary);
const issues = Array.isArray(zone.issues) ? zone.issues.length : null;
rows.push({
name,
plan: text(zone.plan, 40) ?? text(record(zone.plan)?.name, 40),
status: text(zone.status, 20),
grade: text(zone.grade, 3),
score: score(zone.score),
passed: count(summary?.passed),
warnings: count(summary?.warnings),
failed: count(summary?.failed),
issues,
});
}
return rows;
}
/**
* The view of a checkup result (`CheckupResult`, its locked projection, or a
* legacy One report), or null when `value` is not an object. Malformed parts
* are left out rather than failing the whole view.
*/
export function readCheckupReport(value) {
const result = record(value);
if (!result)
return null;
const account = record(result.account);
const checks = list(result.checks);
const isLegacy = !Array.isArray(result.checks) && Array.isArray(result.issues);
return {
accountName: text(account?.name, 120) ?? text(result.accountName, 120),
zoneCount: count(account?.zoneCount) ?? (isLegacy ? count(record(result.summary)?.totalZones) : null),
scannedZones: count(account?.scannedZones),
grade: text(result.grade, 3),
score: score(result.score),
summary: isLegacy ? null : counts(result.summary),
zones: zoneRows(list(result.zones)),
findings: group(isLegacy ? issueInstances(list(result.issues)) : checkInstances(checks)),
lockedPremiumCount: count(result.lockedPremiumCount) ?? 0,
shareableUrl: reportUrl(result.shareableUrl),
scannedAt: isoDate(result.scannedAt) ?? isoDate(result.generatedAt),
};
}
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export function joinParts(palette, parts) {
return parts.join(` ${palette.symbols.separator} `);
}
/** `account-wide`, `on 2 domains`, or '' (a legacy issue without a zone list). */
export function findingScope(finding) {
if (finding.account && finding.zones === 0)
return 'account-wide';
if (finding.zones > 0)
return `on ${plural(finding.zones, 'domain')}`;
return '';
}
/** A grade in the colour of its band, bold: A green, B and C yellow, D and F red (the server's letter, coloured). */
export function styleGrade(style, grade) {
const letter = grade.trim().charAt(0).toUpperCase();
const colour = letter === 'A' ? style.green : letter === 'B' || letter === 'C' ? style.yellow : letter === 'D' || letter === 'F' ? style.red : (value) => value;
return style.bold(colour(grade));
}
function styleSeverity(style, severity) {
switch (severity.toLowerCase()) {
case 'critical': return style.bold(style.red(severity));
case 'high': return style.red(severity);
case 'medium':
case 'warning': return style.yellow(severity);
case 'info': return style.dim(severity);
default: return severity;
}
}
/**
* Cells per segment for a stacked bar: proportional, summing to `width`, and
* at least one cell for every non-zero count while the width allows.
*/
export function segmentWidths(values, width) {
const total = values.reduce((sum, value) => sum + Math.max(0, value), 0);
if (total <= 0 || width <= 0)
return values.map(() => 0);
const exact = values.map((value) => (Math.max(0, value) / total) * width);
const cells = exact.map(Math.floor);
values.forEach((value, index) => {
if (value > 0 && cells[index] === 0)
cells[index] = 1;
});
let left = width - cells.reduce((sum, cell) => sum + cell, 0);
const byRemainder = exact.map((value, index) => ({ index, remainder: value - Math.floor(value) })).sort((a, b) => b.remainder - a.remainder);
for (let i = 0; left > 0; i = (i + 1) % byRemainder.length) {
const index = byRemainder[i].index;
if (values[index] > 0) {
cells[index]++;
left--;
}
}
while (left < 0) {
const largest = cells.indexOf(Math.max(...cells));
cells[largest]--;
left++;
}
return cells;
}
/**
* `██████░░`: passed, then warnings and failed (yellow and red with colour),
* then checks with any other status. Without colour, everything but passed is
* the empty cell, so the bar still reads as the share that passed.
*/
export function checksBar(palette, summary, width) {
const passed = summary.passed ?? 0;
const warnings = summary.warnings ?? 0;
const failed = summary.failed ?? 0;
const total = Math.max(summary.total ?? 0, passed + warnings + failed);
if (total === 0 || width < 4)
return null;
const { style, symbols } = palette;
const [p, w, f, rest] = segmentWidths([passed, warnings, failed, total - passed - warnings - failed], width);
return style.green(symbols.barFull.repeat(p))
+ style.yellow(symbols.barFull.repeat(w))
+ style.red(symbols.barFull.repeat(f))
+ style.dim(symbols.barEmpty.repeat(rest));
}
/** Plain bar for output without colour: passed full, everything else empty. */
function monoBar(palette, summary, width) {
const passed = summary.passed ?? 0;
const total = Math.max(summary.total ?? 0, passed + (summary.warnings ?? 0) + (summary.failed ?? 0));
if (total === 0 || width < 4)
return null;
const [full, empty] = segmentWidths([passed, total - passed], width);
return palette.symbols.barFull.repeat(full) + palette.symbols.barEmpty.repeat(empty);
}
function bar(palette, summary, width) {
return palette.color ? checksBar(palette, summary, width) : monoBar(palette, summary, width);
}
/** Fit a styled line to the palette's width (plain output is never cut). */
function fit(palette, line) {
return palette.columns === null ? line : truncate(line, palette.columns - 1, palette.symbols.ellipsis);
}
/**
* Finding lines, aligned: `✗ critical Account MFA Enforcement account-wide`.
* `indent` prefixes every line; with `recommendations`, the first line of the
* recommendation follows each finding, dim, under its name.
*/
export function findingLines(palette, findings, options) {
const { style, symbols } = palette;
const severityWidth = Math.max(0, ...findings.map((finding) => displayWidth(finding.severity ?? '')));
const scopes = findings.map(findingScope);
const scopeWidth = Math.max(0, ...scopes.map(displayWidth));
// indent, icon and space, severity and two spaces, then the name, two spaces and the scope.
const fixed = displayWidth(options.indent) + 2 + (severityWidth ? severityWidth + 2 : 0);
const longest = Math.max(0, ...findings.map((finding) => displayWidth(finding.name)));
const room = palette.columns === null ? longest : Math.max(12, palette.columns - 1 - fixed - (scopeWidth ? scopeWidth + 2 : 0));
const nameWidth = Math.min(longest, room);
const lines = [];
findings.forEach((finding, index) => {
const icon = finding.status === 'failed' ? style.red(symbols.cross) : finding.status === 'warning' ? style.yellow(symbols.warning) : style.dim(symbols.bullet);
const severity = severityWidth ? `${padEnd(styleSeverity(style, finding.severity ?? ''), severityWidth)} ` : '';
const name = truncate(finding.name, nameWidth, symbols.ellipsis);
const scope = scopes[index];
const line = scope ? `${padEnd(name, nameWidth)} ${style.dim(scope)}` : name;
lines.push(fit(palette, `${options.indent}${icon} ${severity}${line}`));
if (options.recommendations && finding.recommendation) {
lines.push(fit(palette, `${options.indent}${' '.repeat(fixed - displayWidth(options.indent))}${style.dim(finding.recommendation)}`));
}
});
return lines;
}
/** `+87 more checks in the full report` */
export function lockedTeaser(palette, locked) {
if (locked <= 0)
return null;
return `${palette.style.brand(`+${locked}`)} more ${locked === 1 ? 'check' : 'checks'} in the full report`;
}
/** Findings shown at the end of a local checkup. */
export const LOCAL_FINDINGS_SHOWN = CLI_CHECKUP_LIMITS.card;
/**
* `… 2 more findings on the results page`: the findings the card leaves out
* are on the results page (the link printed with the card). Running again
* with --json would collect and upload again, so it is not suggested.
*/
function moreLocalFindings(hidden) {
return `${plural(hidden, 'more finding')} on the results page`;
}
/**
* The result card of the terminal UI, under `└ ✓ Done. …`: the free checks as
* a bar, the top findings (failed, then warnings; names and zone counts, never
* zone names) and how many premium checks the full report adds. Lines are
* indented to the end of the timeline and fit the width.
*/
export function localResultCard(palette, view) {
const { style, symbols } = palette;
const indent = ' ';
const lines = [];
const summary = view.summary;
if (summary) {
const passed = summary.passed ?? 0;
const total = Math.max(summary.total ?? 0, passed + (summary.warnings ?? 0) + (summary.failed ?? 0));
const legend = total > 0 ? `${passed} of ${plural(total, 'free check')} passed` : '';
const width = Math.min(32, (palette.columns ?? 80) - 1 - indent.length - 2 - displayWidth(legend));
const drawn = legend ? bar(palette, summary, width) : null;
if (drawn)
lines.push(fit(palette, `${indent}${drawn} ${style.dim(legend)}`));
}
const shown = view.findings.slice(0, LOCAL_FINDINGS_SHOWN);
const teaser = lockedTeaser(palette, view.lockedPremiumCount);
if (shown.length || teaser)
lines.push('');
if (shown.length) {
lines.push(`${indent}${style.bold('Top findings')}`);
lines.push(...findingLines(palette, shown, { indent }));
const hidden = view.findings.length - shown.length;
if (hidden > 0)
lines.push(fit(palette, `${indent}${style.dim(`${symbols.ellipsis} ${moreLocalFindings(hidden)}`)}`));
}
if (teaser)
lines.push(fit(palette, `${indent}${teaser}`));
return lines;
}
/**
* The same, as plain lines appended after the plain result lines:
* `Top findings:`, one indented line each, then the premium teaser. Empty when
* there is nothing to add.
*/
export function localResultPlainLines(view, palette) {
const lines = [];
const shown = view.findings.slice(0, LOCAL_FINDINGS_SHOWN);
if (shown.length) {
lines.push('Top findings:');
const severityWidth = Math.max(0, ...shown.map((finding) => displayWidth(finding.severity ?? '')));
for (const finding of shown) {
const icon = finding.status === 'failed' ? palette.symbols.cross : finding.status === 'warning' ? palette.symbols.warning : palette.symbols.bullet;
const scope = findingScope(finding);
lines.push(` ${icon} ${severityWidth ? `${padEnd(finding.severity ?? '', severityWidth)} ` : ''}${finding.name}${scope ? ` ${palette.symbols.separator} ${scope}` : ''}`);
}
const hidden = view.findings.length - shown.length;
if (hidden > 0)
lines.push(` ${palette.symbols.ellipsis} ${moreLocalFindings(hidden)}`);
}
const teaser = lockedTeaser(palette, view.lockedPremiumCount);
if (teaser)
lines.push(teaser);
return lines;
}
// ============================================================================
// cf checkup result / status
// ============================================================================
/** Priorities shown by `cf checkup result` and `cf checkup`. */
export const PRIORITIES_SHOWN = CLI_CHECKUP_LIMITS.priorities;
/** Domain rows shown before `… N more domains`. */
export const ZONES_SHOWN = CLI_CHECKUP_LIMITS.domains;
/**
* `… 3 more domains in the full report (--json lists them all)`: where to see
* what a list leaves out. The full report is the link printed last, when the
* result has one; --json returns every check and field.
*/
function moreHint(view, hidden, noun) {
return `${plural(hidden, `more ${noun}`)}${view.shareableUrl ? ' in the full report' : ''} (--json lists them all)`;
}
/** `2026-10-08 12:00 UTC` */
function formatUtc(iso) {
return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
}
/** `checks ████░░ 48 passed · 15 warnings · 3 failed · 16 skipped · 109 total` (the bar only on a terminal). */
function countsLine(palette, summary, key) {
const { style } = palette;
const parts = [
summary.passed !== null ? style.green(`${summary.passed} passed`) : null,
summary.warnings !== null ? (summary.warnings > 0 ? style.yellow : style.dim)(plural(summary.warnings, 'warning')) : null,
summary.failed !== null ? (summary.failed > 0 ? style.red : style.dim)(`${summary.failed} failed`) : null,
summary.skipped ? style.dim(`${summary.skipped} skipped`) : null,
summary.total !== null ? style.dim(`${summary.total} total`) : null,
].filter((part) => part !== null);
const text = joinParts(palette, parts);
// The bar only on a terminal, and only where it leaves room for the counts.
const width = palette.columns === null ? 0 : Math.min(24, palette.columns - 1 - displayWidth(key('checks')) - 2 - displayWidth(text));
const drawn = width >= 8 ? bar(palette, summary, width) : null;
return fit(palette, `${key('checks')}${drawn ? `${drawn} ` : ''}${text}`);
}
function zoneTable(palette, view) {
const { style, symbols } = palette;
const zones = view.zones;
// Worst first when the server scored every zone; otherwise the server's order.
const rows = zones.every((zone) => zone.score !== null)
? zones.map((zone, index) => ({ zone, index })).sort((a, b) => a.zone.score - b.zone.score || a.index - b.index).map(({ zone }) => zone)
: [...zones];
const shown = rows.slice(0, ZONES_SHOWN);
const legacy = shown.every((zone) => zone.passed === null && zone.failed === null && zone.warnings === null);
const plans = shown.map((zone) => [zone.plan, zone.status && zone.status !== 'active' ? zone.status : null].filter(Boolean).join(` ${symbols.separator} `));
const grades = shown.map((zone) => [zone.grade, zone.score !== null ? String(zone.score) : null].filter(Boolean).join(' '));
const headers = legacy ? ['Domain', 'Plan', 'Issues'] : ['Domain', 'Plan', 'Grade', 'Failed', 'Warnings', 'Passed'];
const hasGrade = grades.some(Boolean);
const planWidth = Math.max(displayWidth(headers[1]), ...plans.map(displayWidth));
const gradeWidth = hasGrade ? Math.max(5, ...grades.map(displayWidth)) : 0;
const numbers = legacy ? ['Issues'] : ['Failed', 'Warnings', 'Passed'];
const numberWidths = numbers.map((header) => displayWidth(header));
const longest = Math.max(displayWidth('Domain'), ...shown.map((zone) => displayWidth(zone.name)));
const fixedWidth = (gradeWidth ? gradeWidth + 2 : 0) + numberWidths.reduce((sum, width) => sum + width + 2, 0);
// The plan is the first column to go on a narrow terminal.
const hasPlan = plans.some(Boolean) && (palette.columns === null || 2 + longest + planWidth + 2 + fixedWidth <= palette.columns - 1);
const tailWidth = (hasPlan ? planWidth + 2 : 0) + fixedWidth;
const nameWidth = palette.columns === null ? longest : Math.min(longest, Math.max(12, palette.columns - 1 - 2 - tailWidth));
const number = (value, width, colour) => value === null ? ' '.repeat(width - 1) + style.dim('-') : (value > 0 ? colour : style.dim)(String(value).padStart(width));
const head = [
padEnd('Domain', nameWidth),
...(hasPlan ? [padEnd('Plan', planWidth)] : []),
...(gradeWidth ? [padEnd('Grade', gradeWidth)] : []),
...numbers.map((header, index) => header.padStart(numberWidths[index])),
].join(' ');
const lines = [fit(palette, ` ${style.dim(head)}`)];
shown.forEach((zone, index) => {
const cells = [
padEnd(truncate(zone.name, nameWidth, symbols.ellipsis), nameWidth),
...(hasPlan ? [style.dim(padEnd(truncate(plans[index], planWidth, symbols.ellipsis), planWidth))] : []),
...(gradeWidth ? [zone.grade ? `${styleGrade(style, zone.grade)}${padEnd(zone.score !== null ? ` ${zone.score}` : '', gradeWidth - displayWidth(zone.grade))}` : padEnd(grades[index], gradeWidth)] : []),
...(legacy
? [number(zone.issues, numberWidths[0], style.yellow)]
: [number(zone.failed, numberWidths[0], style.red), number(zone.warnings, numberWidths[1], style.yellow), number(zone.passed, numberWidths[2], style.green)]),
];
lines.push(fit(palette, ` ${cells.join(' ')}`.trimEnd()));
});
if (rows.length > shown.length)
lines.push(fit(palette, ` ${style.dim(`${symbols.ellipsis} ${moreHint(view, rows.length - shown.length, 'domain')}`)}`));
return lines;
}
/**
* The body of `cf checkup result` and `cf checkup` in text mode, after their
* `checkup` and `grade` lines: the account, the counts (a bar on a terminal),
* the domains, the top priorities with the first line of each recommendation,
* the premium teaser of a locked result and the full report link.
*/
export function checkupReportLines(palette, view) {
const { style } = palette;
const key = (name) => name.padEnd(11);
const lines = [];
const accountParts = [
view.accountName,
view.zoneCount !== null
? (view.scannedZones !== null && view.scannedZones !== view.zoneCount ? `${view.scannedZones} of ${plural(view.zoneCount, 'domain')} scanned` : plural(view.zoneCount, 'domain'))
: view.zones.length ? plural(view.zones.length, 'domain') : null,
].filter((part) => part !== null);
if (accountParts.length)
lines.push(fit(palette, `${key('account')}${joinParts(palette, accountParts)}`));
if (view.scannedAt)
lines.push(`${key('scanned')}${formatUtc(view.scannedAt)}`);
if (view.summary)
lines.push(countsLine(palette, view.summary, key));
if (view.zones.length) {
lines.push('', style.bold('Domains'), ...zoneTable(palette, view));
}
if (view.findings.length) {
const shown = view.findings.slice(0, PRIORITIES_SHOWN);
lines.push('', style.bold('Top priorities'), ...findingLines(palette, shown, { indent: ' ', recommendations: true }));
const hidden = view.findings.length - shown.length;
if (hidden > 0)
lines.push(fit(palette, ` ${style.dim(`${palette.symbols.ellipsis} ${moreHint(view, hidden, 'finding')}`)}`));
}
else if (view.summary && (view.summary.failed ?? 0) === 0 && (view.summary.warnings ?? 0) === 0) {
lines.push('', `${style.green(palette.symbols.check)} No failed checks or warnings.`);
}
const teaser = lockedTeaser(palette, view.lockedPremiumCount);
if (teaser)
lines.push('', teaser);
if (view.shareableUrl)
lines.push('', `Full report: ${style.underline(view.shareableUrl)}`);
return lines;
}