UNPKG

@vigilbase/cli

Version:

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

504 lines (503 loc) • 25.6 kB
/** * 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; }