@vigilbase/cli 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLOUDFLARE.md +9 -2
- package/COMMANDS.md +5 -5
- package/LOCAL.md +87 -12
- package/ONE.md +1 -1
- package/README.md +13 -10
- package/dist/commands/cloudflare.js +79 -13
- package/dist/commands/helpers.d.ts +4 -1
- package/dist/commands/local.js +44 -23
- package/dist/lib/checkup-report-limits.d.ts +16 -0
- package/dist/lib/checkup-report-limits.js +16 -0
- package/dist/lib/checkup-report.d.ts +118 -0
- package/dist/lib/checkup-report.js +503 -0
- package/dist/lib/format.d.ts +2 -0
- package/dist/lib/format.js +1 -1
- package/dist/lib/tui.d.ts +4 -0
- package/dist/lib/tui.js +2 -2
- package/dist/local/cloudflare-checkup/cf/allowlist.js +5 -2
- package/dist/local/cloudflare-checkup/collector.js +5 -5
- package/dist/local/cloudflare-checkup/contract.d.ts +481 -20
- package/dist/local/cloudflare-checkup/contract.js +111 -27
- package/dist/local/cloudflare-checkup/email-verification.d.ts +8 -1
- package/dist/local/cloudflare-checkup/email-verification.js +0 -5
- package/dist/local/cloudflare-checkup/identity-verification.d.ts +11 -0
- package/dist/local/cloudflare-checkup/identity-verification.js +65 -0
- package/dist/local/cloudflare-checkup/snapshot-file.js +13 -1
- package/dist/local/cloudflare-checkup/submit-client.d.ts +6 -3
- package/dist/local/cloudflare-checkup/submit-client.js +17 -4
- package/dist/local/cloudflare-checkup/terminal-ui.d.ts +10 -3
- package/dist/local/cloudflare-checkup/terminal-ui.js +13 -3
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cloudflare Checkup list caps the CLI prints, from `CHECKUP_REPORT_LIMITS`
|
|
3
|
+
* in packages/tools/src/pdf/cloudflare-checkup-report-limits.ts, so the
|
|
4
|
+
* terminal cuts long lists where the web report, the PDF and the emails do.
|
|
5
|
+
* The CLI is published to npm and cannot depend on the private
|
|
6
|
+
* `@vigilbase/tools` package; checkup-report-limits.test.ts keeps these values
|
|
7
|
+
* equal to the source.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CLI_CHECKUP_LIMITS: {
|
|
10
|
+
/** Top priorities of `cf checkup result` and `cf checkup` (`priorities.cli`). */
|
|
11
|
+
readonly priorities: 8;
|
|
12
|
+
/** Their domain table rows (`cliDomains`). */
|
|
13
|
+
readonly domains: 15;
|
|
14
|
+
/** Top findings on the local checkup's result card (`priorities.cliCard`). */
|
|
15
|
+
readonly card: 5;
|
|
16
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cloudflare Checkup list caps the CLI prints, from `CHECKUP_REPORT_LIMITS`
|
|
3
|
+
* in packages/tools/src/pdf/cloudflare-checkup-report-limits.ts, so the
|
|
4
|
+
* terminal cuts long lists where the web report, the PDF and the emails do.
|
|
5
|
+
* The CLI is published to npm and cannot depend on the private
|
|
6
|
+
* `@vigilbase/tools` package; checkup-report-limits.test.ts keeps these values
|
|
7
|
+
* equal to the source.
|
|
8
|
+
*/
|
|
9
|
+
export const CLI_CHECKUP_LIMITS = {
|
|
10
|
+
/** Top priorities of `cf checkup result` and `cf checkup` (`priorities.cli`). */
|
|
11
|
+
priorities: 8,
|
|
12
|
+
/** Their domain table rows (`cliDomains`). */
|
|
13
|
+
domains: 15,
|
|
14
|
+
/** Top findings on the local checkup's result card (`priorities.cliCard`). */
|
|
15
|
+
card: 5,
|
|
16
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type Styles, type Symbols } from './tui.js';
|
|
2
|
+
export type CheckupCounts = {
|
|
3
|
+
total: number | null;
|
|
4
|
+
passed: number | null;
|
|
5
|
+
warnings: number | null;
|
|
6
|
+
failed: number | null;
|
|
7
|
+
skipped: number | null;
|
|
8
|
+
};
|
|
9
|
+
/** One check ID's failing or warning instances, across the account and its zones. */
|
|
10
|
+
export type CheckupFinding = {
|
|
11
|
+
name: string;
|
|
12
|
+
/** The server's label (`critical`, `high`, …), as sent. */
|
|
13
|
+
severity: string | null;
|
|
14
|
+
/** `failed` (fail or error) or `warning`; null for a legacy report's issue, which has no status. */
|
|
15
|
+
status: 'failed' | 'warning' | null;
|
|
16
|
+
/** Distinct zones with this finding. */
|
|
17
|
+
zones: number;
|
|
18
|
+
/** An account-level check. */
|
|
19
|
+
account: boolean;
|
|
20
|
+
/** The first line of the server's recommendation. */
|
|
21
|
+
recommendation: string | null;
|
|
22
|
+
};
|
|
23
|
+
export type CheckupZoneRow = {
|
|
24
|
+
name: string;
|
|
25
|
+
plan: string | null;
|
|
26
|
+
status: string | null;
|
|
27
|
+
grade: string | null;
|
|
28
|
+
score: number | null;
|
|
29
|
+
passed: number | null;
|
|
30
|
+
warnings: number | null;
|
|
31
|
+
failed: number | null;
|
|
32
|
+
/** A legacy report's issue count (it has no pass/warning/fail summary). */
|
|
33
|
+
issues: number | null;
|
|
34
|
+
};
|
|
35
|
+
export type CheckupReportView = {
|
|
36
|
+
accountName: string | null;
|
|
37
|
+
zoneCount: number | null;
|
|
38
|
+
scannedZones: number | null;
|
|
39
|
+
grade: string | null;
|
|
40
|
+
score: number | null;
|
|
41
|
+
summary: CheckupCounts | null;
|
|
42
|
+
zones: CheckupZoneRow[];
|
|
43
|
+
/** Ordered: failed before warnings, then by the server's severity, then the widest. */
|
|
44
|
+
findings: CheckupFinding[];
|
|
45
|
+
/** Premium checks the locked projection holds back. */
|
|
46
|
+
lockedPremiumCount: number;
|
|
47
|
+
/** The results page, only when it is an https://vigilbase.com URL. */
|
|
48
|
+
shareableUrl: string | null;
|
|
49
|
+
scannedAt: string | null;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The view of a checkup result (`CheckupResult`, its locked projection, or a
|
|
53
|
+
* legacy One report), or null when `value` is not an object. Malformed parts
|
|
54
|
+
* are left out rather than failing the whole view.
|
|
55
|
+
*/
|
|
56
|
+
export declare function readCheckupReport(value: unknown): CheckupReportView | null;
|
|
57
|
+
/** How the lines are drawn: styles (identity without colour), symbols (ASCII on legacy consoles) and the width to fit, if any. */
|
|
58
|
+
export type ReportPalette = {
|
|
59
|
+
style: Styles;
|
|
60
|
+
symbols: Symbols;
|
|
61
|
+
/** Colours on: the bar shows warnings and failed checks in their colours. */
|
|
62
|
+
color: boolean;
|
|
63
|
+
/** Terminal columns; null for plain output (pipes, files), which is never cut. */
|
|
64
|
+
columns: number | null;
|
|
65
|
+
};
|
|
66
|
+
export declare function joinParts(palette: ReportPalette, parts: readonly string[]): string;
|
|
67
|
+
/** `account-wide`, `on 2 domains`, or '' (a legacy issue without a zone list). */
|
|
68
|
+
export declare function findingScope(finding: CheckupFinding): string;
|
|
69
|
+
/** A grade in the colour of its band, bold: A green, B and C yellow, D and F red (the server's letter, coloured). */
|
|
70
|
+
export declare function styleGrade(style: Styles, grade: string): string;
|
|
71
|
+
/**
|
|
72
|
+
* Cells per segment for a stacked bar: proportional, summing to `width`, and
|
|
73
|
+
* at least one cell for every non-zero count while the width allows.
|
|
74
|
+
*/
|
|
75
|
+
export declare function segmentWidths(values: readonly number[], width: number): number[];
|
|
76
|
+
/**
|
|
77
|
+
* `██████░░`: passed, then warnings and failed (yellow and red with colour),
|
|
78
|
+
* then checks with any other status. Without colour, everything but passed is
|
|
79
|
+
* the empty cell, so the bar still reads as the share that passed.
|
|
80
|
+
*/
|
|
81
|
+
export declare function checksBar(palette: ReportPalette, summary: CheckupCounts, width: number): string | null;
|
|
82
|
+
/**
|
|
83
|
+
* Finding lines, aligned: `✗ critical Account MFA Enforcement account-wide`.
|
|
84
|
+
* `indent` prefixes every line; with `recommendations`, the first line of the
|
|
85
|
+
* recommendation follows each finding, dim, under its name.
|
|
86
|
+
*/
|
|
87
|
+
export declare function findingLines(palette: ReportPalette, findings: readonly CheckupFinding[], options: {
|
|
88
|
+
indent: string;
|
|
89
|
+
recommendations?: boolean;
|
|
90
|
+
}): string[];
|
|
91
|
+
/** `+87 more checks in the full report` */
|
|
92
|
+
export declare function lockedTeaser(palette: ReportPalette, locked: number): string | null;
|
|
93
|
+
/** Findings shown at the end of a local checkup. */
|
|
94
|
+
export declare const LOCAL_FINDINGS_SHOWN: 5;
|
|
95
|
+
/**
|
|
96
|
+
* The result card of the terminal UI, under `└ ✓ Done. …`: the free checks as
|
|
97
|
+
* a bar, the top findings (failed, then warnings; names and zone counts, never
|
|
98
|
+
* zone names) and how many premium checks the full report adds. Lines are
|
|
99
|
+
* indented to the end of the timeline and fit the width.
|
|
100
|
+
*/
|
|
101
|
+
export declare function localResultCard(palette: ReportPalette, view: CheckupReportView): string[];
|
|
102
|
+
/**
|
|
103
|
+
* The same, as plain lines appended after the plain result lines:
|
|
104
|
+
* `Top findings:`, one indented line each, then the premium teaser. Empty when
|
|
105
|
+
* there is nothing to add.
|
|
106
|
+
*/
|
|
107
|
+
export declare function localResultPlainLines(view: CheckupReportView, palette: ReportPalette): string[];
|
|
108
|
+
/** Priorities shown by `cf checkup result` and `cf checkup`. */
|
|
109
|
+
export declare const PRIORITIES_SHOWN: 8;
|
|
110
|
+
/** Domain rows shown before `… N more domains`. */
|
|
111
|
+
export declare const ZONES_SHOWN: 15;
|
|
112
|
+
/**
|
|
113
|
+
* The body of `cf checkup result` and `cf checkup` in text mode, after their
|
|
114
|
+
* `checkup` and `grade` lines: the account, the counts (a bar on a terminal),
|
|
115
|
+
* the domains, the top priorities with the first line of each recommendation,
|
|
116
|
+
* the premium teaser of a locked result and the full report link.
|
|
117
|
+
*/
|
|
118
|
+
export declare function checkupReportLines(palette: ReportPalette, view: CheckupReportView): string[];
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A readable Cloudflare Checkup result, from what the server sent: the
|
|
3
|
+
* locked free projection a local checkup's upload returns, or the stored
|
|
4
|
+
* result One returns for `cf checkup result` and `cf checkup`.
|
|
5
|
+
*
|
|
6
|
+
* Presentation only. Grades, scores, severities, statuses, counts and names
|
|
7
|
+
* are the server's; nothing here computes a grade or assigns a severity. It
|
|
8
|
+
* groups the server's checks by check ID, orders them by the server's status
|
|
9
|
+
* and severity, and fits them to the terminal.
|
|
10
|
+
*
|
|
11
|
+
* Every string from the payload is sanitised when it is read (no terminal
|
|
12
|
+
* controls, bidi overrides or line breaks), so the views only ever hold text
|
|
13
|
+
* that is safe to print; styles are added by the renderers.
|
|
14
|
+
*/
|
|
15
|
+
import { CLI_CHECKUP_LIMITS } from './checkup-report-limits.js';
|
|
16
|
+
import { reportUrl, sanitizeTerminal } from './format.js';
|
|
17
|
+
import { displayWidth, padEnd, truncate } from './tui.js';
|
|
18
|
+
/** Longest text kept from the payload; the renderers cut to the terminal width. */
|
|
19
|
+
const MAX_TEXT = 300;
|
|
20
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
21
|
+
const list = (value) => Array.isArray(value) ? value.map(record).filter((item) => item !== null) : [];
|
|
22
|
+
/** Untrusted text on one line: controls, bidi overrides and line breaks removed, whitespace collapsed. */
|
|
23
|
+
function text(value, max = MAX_TEXT) {
|
|
24
|
+
if (typeof value !== 'string')
|
|
25
|
+
return null;
|
|
26
|
+
const flat = sanitizeTerminal(value.replace(/[\s\u0085\u2028\u2029]+/g, ' ')).trim();
|
|
27
|
+
return flat === '' ? null : [...flat].slice(0, max).join('');
|
|
28
|
+
}
|
|
29
|
+
const count = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
|
|
30
|
+
const score = (value) => typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : null;
|
|
31
|
+
/** A valid timestamp, normalised to ISO 8601 UTC. */
|
|
32
|
+
function isoDate(value) {
|
|
33
|
+
if (typeof value !== 'string')
|
|
34
|
+
return null;
|
|
35
|
+
const time = Date.parse(value);
|
|
36
|
+
return Number.isNaN(time) ? null : new Date(time).toISOString();
|
|
37
|
+
}
|
|
38
|
+
/** The first non-empty line of untrusted text. */
|
|
39
|
+
function firstLine(value) {
|
|
40
|
+
if (typeof value !== 'string')
|
|
41
|
+
return null;
|
|
42
|
+
for (const line of value.split(/\r\n|[\n\r\u0085\u2028\u2029]/)) {
|
|
43
|
+
const flat = text(line);
|
|
44
|
+
if (flat)
|
|
45
|
+
return flat;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'warning', 'low', 'info'];
|
|
50
|
+
const severityRank = (severity) => {
|
|
51
|
+
const rank = SEVERITY_ORDER.indexOf((severity ?? '').toLowerCase());
|
|
52
|
+
return rank === -1 ? SEVERITY_ORDER.length : rank;
|
|
53
|
+
};
|
|
54
|
+
const statusRank = (status) => (status === 'failed' ? 0 : status === 'warning' ? 1 : 2);
|
|
55
|
+
function counts(value) {
|
|
56
|
+
const summary = record(value);
|
|
57
|
+
if (!summary)
|
|
58
|
+
return null;
|
|
59
|
+
const view = { total: count(summary.total), passed: count(summary.passed), warnings: count(summary.warnings), failed: count(summary.failed), skipped: count(summary.skipped) };
|
|
60
|
+
return view.passed === null && view.warnings === null && view.failed === null ? null : view;
|
|
61
|
+
}
|
|
62
|
+
/** Instances of the same check ID become one finding; the most severe of the server's labels is shown. */
|
|
63
|
+
function group(instances) {
|
|
64
|
+
const groups = new Map();
|
|
65
|
+
for (const instance of instances) {
|
|
66
|
+
let entry = groups.get(instance.key);
|
|
67
|
+
if (!entry) {
|
|
68
|
+
entry = { finding: { name: instance.name, severity: instance.severity, status: instance.status, zones: 0, account: false, recommendation: null }, zones: new Set() };
|
|
69
|
+
groups.set(instance.key, entry);
|
|
70
|
+
}
|
|
71
|
+
const { finding } = entry;
|
|
72
|
+
if (statusRank(instance.status) < statusRank(finding.status))
|
|
73
|
+
finding.status = instance.status;
|
|
74
|
+
if (severityRank(instance.severity) < severityRank(finding.severity))
|
|
75
|
+
finding.severity = instance.severity;
|
|
76
|
+
if (instance.zone !== null)
|
|
77
|
+
entry.zones.add(instance.zone);
|
|
78
|
+
if (instance.account)
|
|
79
|
+
finding.account = true;
|
|
80
|
+
finding.recommendation ??= instance.recommendation;
|
|
81
|
+
}
|
|
82
|
+
const findings = [...groups.values()].map(({ finding, zones }) => ({ ...finding, zones: zones.size }));
|
|
83
|
+
return findings
|
|
84
|
+
.map((finding, index) => ({ finding, index }))
|
|
85
|
+
.sort((a, b) => statusRank(a.finding.status) - statusRank(b.finding.status)
|
|
86
|
+
|| severityRank(a.finding.severity) - severityRank(b.finding.severity)
|
|
87
|
+
|| Number(b.finding.account) - Number(a.finding.account)
|
|
88
|
+
|| b.finding.zones - a.finding.zones
|
|
89
|
+
|| a.index - b.index)
|
|
90
|
+
.map(({ finding }) => finding);
|
|
91
|
+
}
|
|
92
|
+
function checkInstances(checks) {
|
|
93
|
+
const instances = [];
|
|
94
|
+
for (const check of checks) {
|
|
95
|
+
const status = check.status === 'fail' || check.status === 'error' ? 'failed' : check.status === 'warning' ? 'warning' : null;
|
|
96
|
+
if (status === null)
|
|
97
|
+
continue;
|
|
98
|
+
const name = text(check.name, 120) ?? text(check.id, 120);
|
|
99
|
+
if (name === null)
|
|
100
|
+
continue;
|
|
101
|
+
const zone = text(check.zoneName) ?? text(check.zoneId);
|
|
102
|
+
instances.push({
|
|
103
|
+
key: typeof check.id === 'string' && check.id !== '' ? `id:${check.id}` : `name:${name}`,
|
|
104
|
+
name,
|
|
105
|
+
severity: text(check.severity, 12),
|
|
106
|
+
status,
|
|
107
|
+
zone,
|
|
108
|
+
account: check.scope === 'account' || zone === null,
|
|
109
|
+
recommendation: firstLine(check.recommendation),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return instances;
|
|
113
|
+
}
|
|
114
|
+
/** A legacy One report's issues (no status; severity `critical`, `warning` or `info`). */
|
|
115
|
+
function issueInstances(issues) {
|
|
116
|
+
const instances = [];
|
|
117
|
+
for (const issue of issues) {
|
|
118
|
+
const name = text(issue.title, 120);
|
|
119
|
+
if (name === null)
|
|
120
|
+
continue;
|
|
121
|
+
const zone = text(issue.zoneName) ?? text(issue.zoneId);
|
|
122
|
+
instances.push({
|
|
123
|
+
// Legacy issue IDs may carry the zone, so the same issue is grouped by its title.
|
|
124
|
+
key: `name:${name}`,
|
|
125
|
+
name,
|
|
126
|
+
severity: text(issue.severity, 12),
|
|
127
|
+
status: null,
|
|
128
|
+
zone,
|
|
129
|
+
account: zone === null,
|
|
130
|
+
recommendation: firstLine(issue.recommendation),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return instances;
|
|
134
|
+
}
|
|
135
|
+
function zoneRows(zones) {
|
|
136
|
+
const rows = [];
|
|
137
|
+
for (const zone of zones) {
|
|
138
|
+
const name = text(zone.name, 120) ?? text(zone.zoneName, 120);
|
|
139
|
+
if (name === null)
|
|
140
|
+
continue;
|
|
141
|
+
const summary = record(zone.summary);
|
|
142
|
+
const issues = Array.isArray(zone.issues) ? zone.issues.length : null;
|
|
143
|
+
rows.push({
|
|
144
|
+
name,
|
|
145
|
+
plan: text(zone.plan, 40) ?? text(record(zone.plan)?.name, 40),
|
|
146
|
+
status: text(zone.status, 20),
|
|
147
|
+
grade: text(zone.grade, 3),
|
|
148
|
+
score: score(zone.score),
|
|
149
|
+
passed: count(summary?.passed),
|
|
150
|
+
warnings: count(summary?.warnings),
|
|
151
|
+
failed: count(summary?.failed),
|
|
152
|
+
issues,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return rows;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The view of a checkup result (`CheckupResult`, its locked projection, or a
|
|
159
|
+
* legacy One report), or null when `value` is not an object. Malformed parts
|
|
160
|
+
* are left out rather than failing the whole view.
|
|
161
|
+
*/
|
|
162
|
+
export function readCheckupReport(value) {
|
|
163
|
+
const result = record(value);
|
|
164
|
+
if (!result)
|
|
165
|
+
return null;
|
|
166
|
+
const account = record(result.account);
|
|
167
|
+
const checks = list(result.checks);
|
|
168
|
+
const isLegacy = !Array.isArray(result.checks) && Array.isArray(result.issues);
|
|
169
|
+
return {
|
|
170
|
+
accountName: text(account?.name, 120) ?? text(result.accountName, 120),
|
|
171
|
+
zoneCount: count(account?.zoneCount) ?? (isLegacy ? count(record(result.summary)?.totalZones) : null),
|
|
172
|
+
scannedZones: count(account?.scannedZones),
|
|
173
|
+
grade: text(result.grade, 3),
|
|
174
|
+
score: score(result.score),
|
|
175
|
+
summary: isLegacy ? null : counts(result.summary),
|
|
176
|
+
zones: zoneRows(list(result.zones)),
|
|
177
|
+
findings: group(isLegacy ? issueInstances(list(result.issues)) : checkInstances(checks)),
|
|
178
|
+
lockedPremiumCount: count(result.lockedPremiumCount) ?? 0,
|
|
179
|
+
shareableUrl: reportUrl(result.shareableUrl),
|
|
180
|
+
scannedAt: isoDate(result.scannedAt) ?? isoDate(result.generatedAt),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
184
|
+
export function joinParts(palette, parts) {
|
|
185
|
+
return parts.join(` ${palette.symbols.separator} `);
|
|
186
|
+
}
|
|
187
|
+
/** `account-wide`, `on 2 domains`, or '' (a legacy issue without a zone list). */
|
|
188
|
+
export function findingScope(finding) {
|
|
189
|
+
if (finding.account && finding.zones === 0)
|
|
190
|
+
return 'account-wide';
|
|
191
|
+
if (finding.zones > 0)
|
|
192
|
+
return `on ${plural(finding.zones, 'domain')}`;
|
|
193
|
+
return '';
|
|
194
|
+
}
|
|
195
|
+
/** A grade in the colour of its band, bold: A green, B and C yellow, D and F red (the server's letter, coloured). */
|
|
196
|
+
export function styleGrade(style, grade) {
|
|
197
|
+
const letter = grade.trim().charAt(0).toUpperCase();
|
|
198
|
+
const colour = letter === 'A' ? style.green : letter === 'B' || letter === 'C' ? style.yellow : letter === 'D' || letter === 'F' ? style.red : (value) => value;
|
|
199
|
+
return style.bold(colour(grade));
|
|
200
|
+
}
|
|
201
|
+
function styleSeverity(style, severity) {
|
|
202
|
+
switch (severity.toLowerCase()) {
|
|
203
|
+
case 'critical': return style.bold(style.red(severity));
|
|
204
|
+
case 'high': return style.red(severity);
|
|
205
|
+
case 'medium':
|
|
206
|
+
case 'warning': return style.yellow(severity);
|
|
207
|
+
case 'info': return style.dim(severity);
|
|
208
|
+
default: return severity;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Cells per segment for a stacked bar: proportional, summing to `width`, and
|
|
213
|
+
* at least one cell for every non-zero count while the width allows.
|
|
214
|
+
*/
|
|
215
|
+
export function segmentWidths(values, width) {
|
|
216
|
+
const total = values.reduce((sum, value) => sum + Math.max(0, value), 0);
|
|
217
|
+
if (total <= 0 || width <= 0)
|
|
218
|
+
return values.map(() => 0);
|
|
219
|
+
const exact = values.map((value) => (Math.max(0, value) / total) * width);
|
|
220
|
+
const cells = exact.map(Math.floor);
|
|
221
|
+
values.forEach((value, index) => {
|
|
222
|
+
if (value > 0 && cells[index] === 0)
|
|
223
|
+
cells[index] = 1;
|
|
224
|
+
});
|
|
225
|
+
let left = width - cells.reduce((sum, cell) => sum + cell, 0);
|
|
226
|
+
const byRemainder = exact.map((value, index) => ({ index, remainder: value - Math.floor(value) })).sort((a, b) => b.remainder - a.remainder);
|
|
227
|
+
for (let i = 0; left > 0; i = (i + 1) % byRemainder.length) {
|
|
228
|
+
const index = byRemainder[i].index;
|
|
229
|
+
if (values[index] > 0) {
|
|
230
|
+
cells[index]++;
|
|
231
|
+
left--;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
while (left < 0) {
|
|
235
|
+
const largest = cells.indexOf(Math.max(...cells));
|
|
236
|
+
cells[largest]--;
|
|
237
|
+
left++;
|
|
238
|
+
}
|
|
239
|
+
return cells;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* `██████░░`: passed, then warnings and failed (yellow and red with colour),
|
|
243
|
+
* then checks with any other status. Without colour, everything but passed is
|
|
244
|
+
* the empty cell, so the bar still reads as the share that passed.
|
|
245
|
+
*/
|
|
246
|
+
export function checksBar(palette, summary, width) {
|
|
247
|
+
const passed = summary.passed ?? 0;
|
|
248
|
+
const warnings = summary.warnings ?? 0;
|
|
249
|
+
const failed = summary.failed ?? 0;
|
|
250
|
+
const total = Math.max(summary.total ?? 0, passed + warnings + failed);
|
|
251
|
+
if (total === 0 || width < 4)
|
|
252
|
+
return null;
|
|
253
|
+
const { style, symbols } = palette;
|
|
254
|
+
const [p, w, f, rest] = segmentWidths([passed, warnings, failed, total - passed - warnings - failed], width);
|
|
255
|
+
return style.green(symbols.barFull.repeat(p))
|
|
256
|
+
+ style.yellow(symbols.barFull.repeat(w))
|
|
257
|
+
+ style.red(symbols.barFull.repeat(f))
|
|
258
|
+
+ style.dim(symbols.barEmpty.repeat(rest));
|
|
259
|
+
}
|
|
260
|
+
/** Plain bar for output without colour: passed full, everything else empty. */
|
|
261
|
+
function monoBar(palette, summary, width) {
|
|
262
|
+
const passed = summary.passed ?? 0;
|
|
263
|
+
const total = Math.max(summary.total ?? 0, passed + (summary.warnings ?? 0) + (summary.failed ?? 0));
|
|
264
|
+
if (total === 0 || width < 4)
|
|
265
|
+
return null;
|
|
266
|
+
const [full, empty] = segmentWidths([passed, total - passed], width);
|
|
267
|
+
return palette.symbols.barFull.repeat(full) + palette.symbols.barEmpty.repeat(empty);
|
|
268
|
+
}
|
|
269
|
+
function bar(palette, summary, width) {
|
|
270
|
+
return palette.color ? checksBar(palette, summary, width) : monoBar(palette, summary, width);
|
|
271
|
+
}
|
|
272
|
+
/** Fit a styled line to the palette's width (plain output is never cut). */
|
|
273
|
+
function fit(palette, line) {
|
|
274
|
+
return palette.columns === null ? line : truncate(line, palette.columns - 1, palette.symbols.ellipsis);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Finding lines, aligned: `✗ critical Account MFA Enforcement account-wide`.
|
|
278
|
+
* `indent` prefixes every line; with `recommendations`, the first line of the
|
|
279
|
+
* recommendation follows each finding, dim, under its name.
|
|
280
|
+
*/
|
|
281
|
+
export function findingLines(palette, findings, options) {
|
|
282
|
+
const { style, symbols } = palette;
|
|
283
|
+
const severityWidth = Math.max(0, ...findings.map((finding) => displayWidth(finding.severity ?? '')));
|
|
284
|
+
const scopes = findings.map(findingScope);
|
|
285
|
+
const scopeWidth = Math.max(0, ...scopes.map(displayWidth));
|
|
286
|
+
// indent, icon and space, severity and two spaces, then the name, two spaces and the scope.
|
|
287
|
+
const fixed = displayWidth(options.indent) + 2 + (severityWidth ? severityWidth + 2 : 0);
|
|
288
|
+
const longest = Math.max(0, ...findings.map((finding) => displayWidth(finding.name)));
|
|
289
|
+
const room = palette.columns === null ? longest : Math.max(12, palette.columns - 1 - fixed - (scopeWidth ? scopeWidth + 2 : 0));
|
|
290
|
+
const nameWidth = Math.min(longest, room);
|
|
291
|
+
const lines = [];
|
|
292
|
+
findings.forEach((finding, index) => {
|
|
293
|
+
const icon = finding.status === 'failed' ? style.red(symbols.cross) : finding.status === 'warning' ? style.yellow(symbols.warning) : style.dim(symbols.bullet);
|
|
294
|
+
const severity = severityWidth ? `${padEnd(styleSeverity(style, finding.severity ?? ''), severityWidth)} ` : '';
|
|
295
|
+
const name = truncate(finding.name, nameWidth, symbols.ellipsis);
|
|
296
|
+
const scope = scopes[index];
|
|
297
|
+
const line = scope ? `${padEnd(name, nameWidth)} ${style.dim(scope)}` : name;
|
|
298
|
+
lines.push(fit(palette, `${options.indent}${icon} ${severity}${line}`));
|
|
299
|
+
if (options.recommendations && finding.recommendation) {
|
|
300
|
+
lines.push(fit(palette, `${options.indent}${' '.repeat(fixed - displayWidth(options.indent))}${style.dim(finding.recommendation)}`));
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
return lines;
|
|
304
|
+
}
|
|
305
|
+
/** `+87 more checks in the full report` */
|
|
306
|
+
export function lockedTeaser(palette, locked) {
|
|
307
|
+
if (locked <= 0)
|
|
308
|
+
return null;
|
|
309
|
+
return `${palette.style.brand(`+${locked}`)} more ${locked === 1 ? 'check' : 'checks'} in the full report`;
|
|
310
|
+
}
|
|
311
|
+
/** Findings shown at the end of a local checkup. */
|
|
312
|
+
export const LOCAL_FINDINGS_SHOWN = CLI_CHECKUP_LIMITS.card;
|
|
313
|
+
/**
|
|
314
|
+
* `… 2 more findings on the results page`: the findings the card leaves out
|
|
315
|
+
* are on the results page (the link printed with the card). Running again
|
|
316
|
+
* with --json would collect and upload again, so it is not suggested.
|
|
317
|
+
*/
|
|
318
|
+
function moreLocalFindings(hidden) {
|
|
319
|
+
return `${plural(hidden, 'more finding')} on the results page`;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* The result card of the terminal UI, under `└ ✓ Done. …`: the free checks as
|
|
323
|
+
* a bar, the top findings (failed, then warnings; names and zone counts, never
|
|
324
|
+
* zone names) and how many premium checks the full report adds. Lines are
|
|
325
|
+
* indented to the end of the timeline and fit the width.
|
|
326
|
+
*/
|
|
327
|
+
export function localResultCard(palette, view) {
|
|
328
|
+
const { style, symbols } = palette;
|
|
329
|
+
const indent = ' ';
|
|
330
|
+
const lines = [];
|
|
331
|
+
const summary = view.summary;
|
|
332
|
+
if (summary) {
|
|
333
|
+
const passed = summary.passed ?? 0;
|
|
334
|
+
const total = Math.max(summary.total ?? 0, passed + (summary.warnings ?? 0) + (summary.failed ?? 0));
|
|
335
|
+
const legend = total > 0 ? `${passed} of ${plural(total, 'free check')} passed` : '';
|
|
336
|
+
const width = Math.min(32, (palette.columns ?? 80) - 1 - indent.length - 2 - displayWidth(legend));
|
|
337
|
+
const drawn = legend ? bar(palette, summary, width) : null;
|
|
338
|
+
if (drawn)
|
|
339
|
+
lines.push(fit(palette, `${indent}${drawn} ${style.dim(legend)}`));
|
|
340
|
+
}
|
|
341
|
+
const shown = view.findings.slice(0, LOCAL_FINDINGS_SHOWN);
|
|
342
|
+
const teaser = lockedTeaser(palette, view.lockedPremiumCount);
|
|
343
|
+
if (shown.length || teaser)
|
|
344
|
+
lines.push('');
|
|
345
|
+
if (shown.length) {
|
|
346
|
+
lines.push(`${indent}${style.bold('Top findings')}`);
|
|
347
|
+
lines.push(...findingLines(palette, shown, { indent }));
|
|
348
|
+
const hidden = view.findings.length - shown.length;
|
|
349
|
+
if (hidden > 0)
|
|
350
|
+
lines.push(fit(palette, `${indent}${style.dim(`${symbols.ellipsis} ${moreLocalFindings(hidden)}`)}`));
|
|
351
|
+
}
|
|
352
|
+
if (teaser)
|
|
353
|
+
lines.push(fit(palette, `${indent}${teaser}`));
|
|
354
|
+
return lines;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The same, as plain lines appended after the plain result lines:
|
|
358
|
+
* `Top findings:`, one indented line each, then the premium teaser. Empty when
|
|
359
|
+
* there is nothing to add.
|
|
360
|
+
*/
|
|
361
|
+
export function localResultPlainLines(view, palette) {
|
|
362
|
+
const lines = [];
|
|
363
|
+
const shown = view.findings.slice(0, LOCAL_FINDINGS_SHOWN);
|
|
364
|
+
if (shown.length) {
|
|
365
|
+
lines.push('Top findings:');
|
|
366
|
+
const severityWidth = Math.max(0, ...shown.map((finding) => displayWidth(finding.severity ?? '')));
|
|
367
|
+
for (const finding of shown) {
|
|
368
|
+
const icon = finding.status === 'failed' ? palette.symbols.cross : finding.status === 'warning' ? palette.symbols.warning : palette.symbols.bullet;
|
|
369
|
+
const scope = findingScope(finding);
|
|
370
|
+
lines.push(` ${icon} ${severityWidth ? `${padEnd(finding.severity ?? '', severityWidth)} ` : ''}${finding.name}${scope ? ` ${palette.symbols.separator} ${scope}` : ''}`);
|
|
371
|
+
}
|
|
372
|
+
const hidden = view.findings.length - shown.length;
|
|
373
|
+
if (hidden > 0)
|
|
374
|
+
lines.push(` ${palette.symbols.ellipsis} ${moreLocalFindings(hidden)}`);
|
|
375
|
+
}
|
|
376
|
+
const teaser = lockedTeaser(palette, view.lockedPremiumCount);
|
|
377
|
+
if (teaser)
|
|
378
|
+
lines.push(teaser);
|
|
379
|
+
return lines;
|
|
380
|
+
}
|
|
381
|
+
// ============================================================================
|
|
382
|
+
// cf checkup result / status
|
|
383
|
+
// ============================================================================
|
|
384
|
+
/** Priorities shown by `cf checkup result` and `cf checkup`. */
|
|
385
|
+
export const PRIORITIES_SHOWN = CLI_CHECKUP_LIMITS.priorities;
|
|
386
|
+
/** Domain rows shown before `… N more domains`. */
|
|
387
|
+
export const ZONES_SHOWN = CLI_CHECKUP_LIMITS.domains;
|
|
388
|
+
/**
|
|
389
|
+
* `… 3 more domains in the full report (--json lists them all)`: where to see
|
|
390
|
+
* what a list leaves out. The full report is the link printed last, when the
|
|
391
|
+
* result has one; --json returns every check and field.
|
|
392
|
+
*/
|
|
393
|
+
function moreHint(view, hidden, noun) {
|
|
394
|
+
return `${plural(hidden, `more ${noun}`)}${view.shareableUrl ? ' in the full report' : ''} (--json lists them all)`;
|
|
395
|
+
}
|
|
396
|
+
/** `2026-10-08 12:00 UTC` */
|
|
397
|
+
function formatUtc(iso) {
|
|
398
|
+
return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
399
|
+
}
|
|
400
|
+
/** `checks ████░░ 48 passed · 15 warnings · 3 failed · 16 skipped · 109 total` (the bar only on a terminal). */
|
|
401
|
+
function countsLine(palette, summary, key) {
|
|
402
|
+
const { style } = palette;
|
|
403
|
+
const parts = [
|
|
404
|
+
summary.passed !== null ? style.green(`${summary.passed} passed`) : null,
|
|
405
|
+
summary.warnings !== null ? (summary.warnings > 0 ? style.yellow : style.dim)(plural(summary.warnings, 'warning')) : null,
|
|
406
|
+
summary.failed !== null ? (summary.failed > 0 ? style.red : style.dim)(`${summary.failed} failed`) : null,
|
|
407
|
+
summary.skipped ? style.dim(`${summary.skipped} skipped`) : null,
|
|
408
|
+
summary.total !== null ? style.dim(`${summary.total} total`) : null,
|
|
409
|
+
].filter((part) => part !== null);
|
|
410
|
+
const text = joinParts(palette, parts);
|
|
411
|
+
// The bar only on a terminal, and only where it leaves room for the counts.
|
|
412
|
+
const width = palette.columns === null ? 0 : Math.min(24, palette.columns - 1 - displayWidth(key('checks')) - 2 - displayWidth(text));
|
|
413
|
+
const drawn = width >= 8 ? bar(palette, summary, width) : null;
|
|
414
|
+
return fit(palette, `${key('checks')}${drawn ? `${drawn} ` : ''}${text}`);
|
|
415
|
+
}
|
|
416
|
+
function zoneTable(palette, view) {
|
|
417
|
+
const { style, symbols } = palette;
|
|
418
|
+
const zones = view.zones;
|
|
419
|
+
// Worst first when the server scored every zone; otherwise the server's order.
|
|
420
|
+
const rows = zones.every((zone) => zone.score !== null)
|
|
421
|
+
? zones.map((zone, index) => ({ zone, index })).sort((a, b) => a.zone.score - b.zone.score || a.index - b.index).map(({ zone }) => zone)
|
|
422
|
+
: [...zones];
|
|
423
|
+
const shown = rows.slice(0, ZONES_SHOWN);
|
|
424
|
+
const legacy = shown.every((zone) => zone.passed === null && zone.failed === null && zone.warnings === null);
|
|
425
|
+
const plans = shown.map((zone) => [zone.plan, zone.status && zone.status !== 'active' ? zone.status : null].filter(Boolean).join(` ${symbols.separator} `));
|
|
426
|
+
const grades = shown.map((zone) => [zone.grade, zone.score !== null ? String(zone.score) : null].filter(Boolean).join(' '));
|
|
427
|
+
const headers = legacy ? ['Domain', 'Plan', 'Issues'] : ['Domain', 'Plan', 'Grade', 'Failed', 'Warnings', 'Passed'];
|
|
428
|
+
const hasGrade = grades.some(Boolean);
|
|
429
|
+
const planWidth = Math.max(displayWidth(headers[1]), ...plans.map(displayWidth));
|
|
430
|
+
const gradeWidth = hasGrade ? Math.max(5, ...grades.map(displayWidth)) : 0;
|
|
431
|
+
const numbers = legacy ? ['Issues'] : ['Failed', 'Warnings', 'Passed'];
|
|
432
|
+
const numberWidths = numbers.map((header) => displayWidth(header));
|
|
433
|
+
const longest = Math.max(displayWidth('Domain'), ...shown.map((zone) => displayWidth(zone.name)));
|
|
434
|
+
const fixedWidth = (gradeWidth ? gradeWidth + 2 : 0) + numberWidths.reduce((sum, width) => sum + width + 2, 0);
|
|
435
|
+
// The plan is the first column to go on a narrow terminal.
|
|
436
|
+
const hasPlan = plans.some(Boolean) && (palette.columns === null || 2 + longest + planWidth + 2 + fixedWidth <= palette.columns - 1);
|
|
437
|
+
const tailWidth = (hasPlan ? planWidth + 2 : 0) + fixedWidth;
|
|
438
|
+
const nameWidth = palette.columns === null ? longest : Math.min(longest, Math.max(12, palette.columns - 1 - 2 - tailWidth));
|
|
439
|
+
const number = (value, width, colour) => value === null ? ' '.repeat(width - 1) + style.dim('-') : (value > 0 ? colour : style.dim)(String(value).padStart(width));
|
|
440
|
+
const head = [
|
|
441
|
+
padEnd('Domain', nameWidth),
|
|
442
|
+
...(hasPlan ? [padEnd('Plan', planWidth)] : []),
|
|
443
|
+
...(gradeWidth ? [padEnd('Grade', gradeWidth)] : []),
|
|
444
|
+
...numbers.map((header, index) => header.padStart(numberWidths[index])),
|
|
445
|
+
].join(' ');
|
|
446
|
+
const lines = [fit(palette, ` ${style.dim(head)}`)];
|
|
447
|
+
shown.forEach((zone, index) => {
|
|
448
|
+
const cells = [
|
|
449
|
+
padEnd(truncate(zone.name, nameWidth, symbols.ellipsis), nameWidth),
|
|
450
|
+
...(hasPlan ? [style.dim(padEnd(truncate(plans[index], planWidth, symbols.ellipsis), planWidth))] : []),
|
|
451
|
+
...(gradeWidth ? [zone.grade ? `${styleGrade(style, zone.grade)}${padEnd(zone.score !== null ? ` ${zone.score}` : '', gradeWidth - displayWidth(zone.grade))}` : padEnd(grades[index], gradeWidth)] : []),
|
|
452
|
+
...(legacy
|
|
453
|
+
? [number(zone.issues, numberWidths[0], style.yellow)]
|
|
454
|
+
: [number(zone.failed, numberWidths[0], style.red), number(zone.warnings, numberWidths[1], style.yellow), number(zone.passed, numberWidths[2], style.green)]),
|
|
455
|
+
];
|
|
456
|
+
lines.push(fit(palette, ` ${cells.join(' ')}`.trimEnd()));
|
|
457
|
+
});
|
|
458
|
+
if (rows.length > shown.length)
|
|
459
|
+
lines.push(fit(palette, ` ${style.dim(`${symbols.ellipsis} ${moreHint(view, rows.length - shown.length, 'domain')}`)}`));
|
|
460
|
+
return lines;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* The body of `cf checkup result` and `cf checkup` in text mode, after their
|
|
464
|
+
* `checkup` and `grade` lines: the account, the counts (a bar on a terminal),
|
|
465
|
+
* the domains, the top priorities with the first line of each recommendation,
|
|
466
|
+
* the premium teaser of a locked result and the full report link.
|
|
467
|
+
*/
|
|
468
|
+
export function checkupReportLines(palette, view) {
|
|
469
|
+
const { style } = palette;
|
|
470
|
+
const key = (name) => name.padEnd(11);
|
|
471
|
+
const lines = [];
|
|
472
|
+
const accountParts = [
|
|
473
|
+
view.accountName,
|
|
474
|
+
view.zoneCount !== null
|
|
475
|
+
? (view.scannedZones !== null && view.scannedZones !== view.zoneCount ? `${view.scannedZones} of ${plural(view.zoneCount, 'domain')} scanned` : plural(view.zoneCount, 'domain'))
|
|
476
|
+
: view.zones.length ? plural(view.zones.length, 'domain') : null,
|
|
477
|
+
].filter((part) => part !== null);
|
|
478
|
+
if (accountParts.length)
|
|
479
|
+
lines.push(fit(palette, `${key('account')}${joinParts(palette, accountParts)}`));
|
|
480
|
+
if (view.scannedAt)
|
|
481
|
+
lines.push(`${key('scanned')}${formatUtc(view.scannedAt)}`);
|
|
482
|
+
if (view.summary)
|
|
483
|
+
lines.push(countsLine(palette, view.summary, key));
|
|
484
|
+
if (view.zones.length) {
|
|
485
|
+
lines.push('', style.bold('Domains'), ...zoneTable(palette, view));
|
|
486
|
+
}
|
|
487
|
+
if (view.findings.length) {
|
|
488
|
+
const shown = view.findings.slice(0, PRIORITIES_SHOWN);
|
|
489
|
+
lines.push('', style.bold('Top priorities'), ...findingLines(palette, shown, { indent: ' ', recommendations: true }));
|
|
490
|
+
const hidden = view.findings.length - shown.length;
|
|
491
|
+
if (hidden > 0)
|
|
492
|
+
lines.push(fit(palette, ` ${style.dim(`${palette.symbols.ellipsis} ${moreHint(view, hidden, 'finding')}`)}`));
|
|
493
|
+
}
|
|
494
|
+
else if (view.summary && (view.summary.failed ?? 0) === 0 && (view.summary.warnings ?? 0) === 0) {
|
|
495
|
+
lines.push('', `${style.green(palette.symbols.check)} No failed checks or warnings.`);
|
|
496
|
+
}
|
|
497
|
+
const teaser = lockedTeaser(palette, view.lockedPremiumCount);
|
|
498
|
+
if (teaser)
|
|
499
|
+
lines.push('', teaser);
|
|
500
|
+
if (view.shareableUrl)
|
|
501
|
+
lines.push('', `Full report: ${style.underline(view.shareableUrl)}`);
|
|
502
|
+
return lines;
|
|
503
|
+
}
|
package/dist/lib/format.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export declare function sanitizeTerminal(value: string): string;
|
|
|
11
11
|
export declare function singleLine(value: string, maxLength?: number): string;
|
|
12
12
|
export declare function createWriter(stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream): Writer;
|
|
13
13
|
export declare function printJson(writer: Writer, value: unknown): void;
|
|
14
|
+
/** The shareable report link, only when it is a plain https://vigilbase.com URL. */
|
|
15
|
+
export declare function reportUrl(value: unknown): string | null;
|
|
14
16
|
export declare function formatToolsList(catalog: AgentToolsCatalogResponse, mode: CliOutputMode, writer: Writer, options?: {
|
|
15
17
|
authRequiredTools?: readonly string[];
|
|
16
18
|
/** @deprecated Use authRequiredTools */
|
package/dist/lib/format.js
CHANGED
|
@@ -203,7 +203,7 @@ function collectFindings(result) {
|
|
|
203
203
|
}
|
|
204
204
|
const MAX_FINDING_LINES = 12;
|
|
205
205
|
/** The shareable report link, only when it is a plain https://vigilbase.com URL. */
|
|
206
|
-
function reportUrl(value) {
|
|
206
|
+
export function reportUrl(value) {
|
|
207
207
|
if (typeof value !== 'string')
|
|
208
208
|
return null;
|
|
209
209
|
let url;
|