@vigilbase/cli 0.2.2 → 0.2.4
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 +2 -2
- package/COMMANDS.md +128 -42
- package/LOCAL.md +99 -38
- package/ONE.md +1 -1
- package/README.md +217 -66
- package/SECURITY.md +1 -1
- package/dist/auth/acl.d.ts +6 -0
- package/dist/auth/acl.js +45 -0
- package/dist/auth/device-auth.d.ts +3 -0
- package/dist/auth/device-auth.js +13 -3
- package/dist/auth/provider.js +4 -4
- package/dist/auth/refresh.d.ts +5 -4
- package/dist/auth/refresh.js +82 -41
- package/dist/auth/store.d.ts +25 -0
- package/dist/auth/store.js +91 -11
- package/dist/bin.js +13 -1
- package/dist/cli.js +205 -81
- package/dist/commands/account.js +11 -7
- package/dist/commands/auth.d.ts +6 -0
- package/dist/commands/auth.js +75 -24
- package/dist/commands/billing.js +2 -2
- package/dist/commands/cloudflare.d.ts +1 -1
- package/dist/commands/cloudflare.js +31 -17
- package/dist/commands/helpers.js +3 -3
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.js +2 -1
- package/dist/commands/legacy-validation.d.ts +20 -0
- package/dist/commands/legacy-validation.js +77 -3
- package/dist/commands/local.js +186 -65
- package/dist/commands/one.js +16 -13
- package/dist/commands/public-tools.js +66 -35
- package/dist/commands/runs.js +5 -5
- package/dist/commands/security.d.ts +1 -1
- package/dist/commands/security.js +41 -37
- package/dist/commands/watchdog.js +5 -5
- package/dist/lib/banner.d.ts +5 -11
- package/dist/lib/banner.js +11 -31
- package/dist/lib/client.d.ts +9 -0
- package/dist/lib/client.js +31 -2
- package/dist/lib/cloudflare-client.d.ts +2 -2
- package/dist/lib/config.d.ts +16 -0
- package/dist/lib/config.js +77 -19
- package/dist/lib/format.d.ts +5 -1
- package/dist/lib/format.js +110 -35
- package/dist/lib/help.js +57 -6
- package/dist/lib/network.d.ts +12 -3
- package/dist/lib/network.js +73 -9
- package/dist/lib/one-client.d.ts +2 -0
- package/dist/lib/one-client.js +86 -12
- package/dist/lib/open-url.js +2 -1
- package/dist/lib/platform.d.ts +2 -0
- package/dist/lib/platform.js +5 -0
- package/dist/lib/progress.d.ts +18 -3
- package/dist/lib/progress.js +72 -10
- package/dist/lib/system-trust.d.ts +2 -0
- package/dist/lib/system-trust.js +6 -0
- package/dist/lib/tui.d.ts +1 -1
- package/dist/lib/tui.js +5 -7
- package/dist/local/cloudflare-checkup/cf/child-env.js +2 -4
- package/dist/local/cloudflare-checkup/cf/install.d.ts +4 -2
- package/dist/local/cloudflare-checkup/cf/install.js +55 -24
- package/dist/local/cloudflare-checkup/cf/locate.d.ts +2 -0
- package/dist/local/cloudflare-checkup/cf/locate.js +10 -1
- package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +18 -3
- package/dist/local/cloudflare-checkup/cf/run-cf.js +75 -17
- package/dist/local/cloudflare-checkup/contract.d.ts +16 -9
- package/dist/local/cloudflare-checkup/contract.js +80 -14
- package/dist/local/cloudflare-checkup/errors.d.ts +34 -1
- package/dist/local/cloudflare-checkup/errors.js +36 -5
- package/dist/local/cloudflare-checkup/output.d.ts +6 -0
- package/dist/local/cloudflare-checkup/output.js +9 -0
- package/dist/local/cloudflare-checkup/snapshot-file.d.ts +6 -2
- package/dist/local/cloudflare-checkup/snapshot-file.js +25 -11
- package/dist/local/cloudflare-checkup/submit-client.js +7 -1
- package/dist/types.d.ts +6 -1
- package/esm-only.cjs +3 -0
- package/package.json +7 -4
package/dist/lib/format.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
-
|
|
2
|
+
import { LOCAL_ERROR_CODES } from '../local/cloudflare-checkup/errors.js';
|
|
3
|
+
/** A scan that ended without a result: com's copy (apps/com/lib/tools/execute-internal-scan.ts). */
|
|
4
|
+
export const SCAN_FAILED_MESSAGE = "The scan couldn't be completed. Try again later.";
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
/**
|
|
7
|
+
* Terminal controls, bidi overrides, and invisible characters an agent reads but a person can't
|
|
8
|
+
* see: zero-width characters, word joiners, the BOM, line separators and tag characters.
|
|
9
|
+
*/
|
|
10
|
+
const TERMINAL_UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu;
|
|
11
|
+
/** The same, less the C0 controls JSON.stringify already escapes. */
|
|
12
|
+
const JSON_UNSAFE = /[\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu;
|
|
13
|
+
/** Untrusted API data must never become terminal control sequences, bidi overrides or invisible text. */
|
|
3
14
|
export function sanitizeTerminal(value) {
|
|
4
|
-
return stripVTControlCharacters(value).replace(
|
|
15
|
+
return stripVTControlCharacters(value).replace(TERMINAL_UNSAFE, '');
|
|
5
16
|
}
|
|
6
17
|
/** One sanitised line, cut to `maxLength` characters with an ellipsis. */
|
|
7
18
|
export function singleLine(value, maxLength = 300) {
|
|
@@ -22,7 +33,7 @@ export function createWriter(stdout = process.stdout, stderr = process.stderr) {
|
|
|
22
33
|
}
|
|
23
34
|
export function printJson(writer, value) {
|
|
24
35
|
// Keep JSON lossless while preventing literal terminal/bidi controls in rendered output.
|
|
25
|
-
writer.stdout(JSON.stringify(value, null, 2).replace(
|
|
36
|
+
writer.stdout(JSON.stringify(value, null, 2).replace(JSON_UNSAFE, (char) => [...Array(char.length).keys()].map((index) => `\\u${char.charCodeAt(index).toString(16).padStart(4, '0')}`).join('')));
|
|
26
37
|
}
|
|
27
38
|
function asRecord(value) {
|
|
28
39
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -153,6 +164,9 @@ function collectFindings(result) {
|
|
|
153
164
|
if (!header)
|
|
154
165
|
continue;
|
|
155
166
|
const status = typeof header.status === 'string' ? header.status : '';
|
|
167
|
+
// An absent header the scanner graded good (X-XSS-Protection) is not a finding.
|
|
168
|
+
if (status === 'good')
|
|
169
|
+
continue;
|
|
156
170
|
if (status !== 'missing' && status !== 'misconfigured' && header.present !== false) {
|
|
157
171
|
continue;
|
|
158
172
|
}
|
|
@@ -188,30 +202,49 @@ function collectFindings(result) {
|
|
|
188
202
|
return findings;
|
|
189
203
|
}
|
|
190
204
|
const MAX_FINDING_LINES = 12;
|
|
205
|
+
/** The shareable report link, only when it is a plain https://vigilbase.com URL. */
|
|
206
|
+
function reportUrl(value) {
|
|
207
|
+
if (typeof value !== 'string')
|
|
208
|
+
return null;
|
|
209
|
+
let url;
|
|
210
|
+
try {
|
|
211
|
+
url = new URL(value);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (url.protocol !== 'https:' || url.hostname !== 'vigilbase.com' || url.port || url.username || url.password)
|
|
217
|
+
return null;
|
|
218
|
+
return url.href;
|
|
219
|
+
}
|
|
191
220
|
/** Compact human lines for a scan result (no UI/skill links, no summary dump). */
|
|
192
221
|
function summarizeResultCompact(result) {
|
|
193
222
|
if (!result)
|
|
194
223
|
return [];
|
|
224
|
+
// Server text never starts a line of its own: each message line is indented like the rest.
|
|
195
225
|
const lines = [];
|
|
196
226
|
const message = typeof result.message === 'string' ? result.message : null;
|
|
197
227
|
if (message)
|
|
198
|
-
lines.push(message);
|
|
228
|
+
lines.push(...sanitizeTerminal(message).split('\n').map((line) => line.trim()).filter(Boolean));
|
|
199
229
|
const counts = summarizeCounts(result);
|
|
200
230
|
if (counts)
|
|
201
231
|
lines.push(counts);
|
|
202
232
|
const findings = collectFindings(result);
|
|
203
233
|
for (const finding of findings.slice(0, MAX_FINDING_LINES)) {
|
|
204
|
-
const sev = finding.severity ? ` ${finding.severity}` : '';
|
|
234
|
+
const sev = finding.severity ? ` ${singleLine(finding.severity, 20)}` : '';
|
|
205
235
|
if (finding.recommendation) {
|
|
206
|
-
lines.push(`✗${sev} ${finding.title} — ${finding.recommendation}`);
|
|
236
|
+
lines.push(`✗${sev} ${singleLine(finding.title)} — ${singleLine(finding.recommendation)}`);
|
|
207
237
|
}
|
|
208
238
|
else {
|
|
209
|
-
lines.push(`✗${sev} ${finding.title}`);
|
|
239
|
+
lines.push(`✗${sev} ${singleLine(finding.title)}`);
|
|
210
240
|
}
|
|
211
241
|
}
|
|
212
242
|
if (findings.length > MAX_FINDING_LINES) {
|
|
213
243
|
lines.push(`… +${findings.length - MAX_FINDING_LINES} more (use --json)`);
|
|
214
244
|
}
|
|
245
|
+
const report = reportUrl(result.shareableUrl);
|
|
246
|
+
if (report)
|
|
247
|
+
lines.push(`Report: ${report}`);
|
|
215
248
|
return lines;
|
|
216
249
|
}
|
|
217
250
|
export function formatToolsList(catalog, mode, writer, options) {
|
|
@@ -219,11 +252,12 @@ export function formatToolsList(catalog, mode, writer, options) {
|
|
|
219
252
|
printJson(writer, catalog);
|
|
220
253
|
return;
|
|
221
254
|
}
|
|
222
|
-
|
|
255
|
+
// Catalog names are the server's: a newline in one must not start a line of its own.
|
|
256
|
+
const publicNames = catalog.tools.map((tool) => singleLine(tool.toolName, 100)).join(' ');
|
|
223
257
|
writer.stdout(`public ${publicNames || '(none)'}`);
|
|
224
258
|
const authRequired = options?.authRequiredTools ?? options?.sensitiveTools ?? [];
|
|
225
259
|
if (authRequired.length) {
|
|
226
|
-
writer.stdout(`auth-req ${authRequired.join(' ')}`);
|
|
260
|
+
writer.stdout(`auth-req ${authRequired.map((name) => singleLine(name, 100)).join(' ')}`);
|
|
227
261
|
}
|
|
228
262
|
if (typeof options?.isAuthenticated === 'boolean') {
|
|
229
263
|
writer.stdout(options.isAuthenticated ? 'auth signed in' : 'auth signed out');
|
|
@@ -231,49 +265,79 @@ export function formatToolsList(catalog, mode, writer, options) {
|
|
|
231
265
|
}
|
|
232
266
|
export function formatToolDescription(tool, mode, writer, skillMarkdown) {
|
|
233
267
|
if (mode === 'json') {
|
|
234
|
-
printJson(writer, { ...tool, skillMarkdown: skillMarkdown ?? null });
|
|
268
|
+
printJson(writer, { success: true, ...tool, skillMarkdown: skillMarkdown ?? null });
|
|
235
269
|
return;
|
|
236
270
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
writer.stdout(`vigilbase run ${tool.toolName} <target>`);
|
|
271
|
+
// Catalog text is the server's: each field stays on its own line.
|
|
272
|
+
writer.stdout(`${singleLine(tool.toolName, 100)} ${singleLine(tool.name)}`);
|
|
273
|
+
writer.stdout(singleLine(tool.description, 2000));
|
|
274
|
+
if (tool.targetDescription)
|
|
275
|
+
writer.stdout(`Target: ${singleLine(tool.targetDescription)}`);
|
|
276
|
+
writer.stdout(`vigilbase run ${singleLine(tool.toolName, 100)} <target>`);
|
|
243
277
|
if (skillMarkdown) {
|
|
244
278
|
writer.stdout();
|
|
245
279
|
writer.stdout(skillMarkdown.trimEnd());
|
|
246
280
|
}
|
|
247
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Why a scan failed, or null when it has a result. A scan the scanner could not grade has
|
|
284
|
+
* `status: "error"` with a message and no grade: One and vigilbase.com both send it as a 422,
|
|
285
|
+
* which the clients turn into a `scan_unassessed` result.
|
|
286
|
+
*/
|
|
287
|
+
function scanFailure(result) {
|
|
288
|
+
if (result.success && result.result?.status !== 'error')
|
|
289
|
+
return null;
|
|
290
|
+
const message = result.result?.status === 'error'
|
|
291
|
+
? result.result.message
|
|
292
|
+
: result.success ? undefined : typeof result.error === 'string' ? result.error : result.error?.message;
|
|
293
|
+
// Vigilbase's scanner wrote it, but it is bounded like any server reason.
|
|
294
|
+
return (typeof message === 'string' && singleLine(message)) || SCAN_FAILED_MESSAGE;
|
|
295
|
+
}
|
|
296
|
+
/** Whether a scan produced a result; the exit code follows it. */
|
|
297
|
+
export function isSuccessfulScan(result) {
|
|
298
|
+
return scanFailure(result) === null;
|
|
299
|
+
}
|
|
248
300
|
export function formatExecuteResult(result, mode, writer) {
|
|
301
|
+
const failure = scanFailure(result);
|
|
249
302
|
if (mode === 'json') {
|
|
250
|
-
|
|
303
|
+
// A scan without a result fails in JSON as in the exit code.
|
|
304
|
+
// A failed scan has no grade, whatever placeholder the scanner used.
|
|
305
|
+
printJson(writer, failure === null ? result : { ...result, success: false, error: failure, grade: null, gradeScore: null });
|
|
251
306
|
return;
|
|
252
307
|
}
|
|
253
|
-
const tool = result.toolName ?? 'tool';
|
|
254
|
-
const target = result.target ?? '';
|
|
255
|
-
const
|
|
256
|
-
|
|
308
|
+
const tool = singleLine(result.toolName ?? 'tool', 100);
|
|
309
|
+
const target = singleLine(result.target ?? '', 2100);
|
|
310
|
+
const gradeLetter = result.grade != null ? singleLine(String(result.grade), 10) : '';
|
|
311
|
+
const grade = failure === null && result.grade != null
|
|
312
|
+
? `${gradeLetter}${result.gradeScore != null ? ` ${singleLine(String(result.gradeScore), 10)}` : ''}`
|
|
257
313
|
: 'n/a';
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
writer.stdout(head);
|
|
263
|
-
else
|
|
264
|
-
writer.stderr(head);
|
|
265
|
-
const error = typeof result.error === 'string' ? result.error : result.error?.message;
|
|
266
|
-
if (error) {
|
|
267
|
-
writer.stderr(` ${error}`);
|
|
314
|
+
if (failure !== null) {
|
|
315
|
+
writer.stderr(`✗ ${tool} · ${target} · ${grade}`);
|
|
316
|
+
writer.stderr(` ${failure}`);
|
|
317
|
+
return;
|
|
268
318
|
}
|
|
319
|
+
// A graded scan exits 0 whatever the grade; only a secure one without an F gets the ✓.
|
|
320
|
+
const isClean = result.result?.status === 'secure' && gradeLetter.toUpperCase() !== 'F';
|
|
321
|
+
writer.stdout(`${isClean ? '✓' : '•'} ${tool} · ${target} · ${grade}`);
|
|
269
322
|
for (const line of summarizeResultCompact(result.result)) {
|
|
270
323
|
writer.stdout(` ${line}`);
|
|
271
324
|
}
|
|
272
325
|
}
|
|
326
|
+
/** Issues of a ZodError, also one from another copy of zod. */
|
|
327
|
+
function zodErrorIssues(error) {
|
|
328
|
+
if (!(error instanceof Error) || error.name !== 'ZodError' || !('issues' in error) || !Array.isArray(error.issues))
|
|
329
|
+
return null;
|
|
330
|
+
return error.issues;
|
|
331
|
+
}
|
|
273
332
|
export function formatError(error, mode, writer) {
|
|
274
333
|
let message = error instanceof Error ? error.message : String(error);
|
|
275
334
|
let reason = '';
|
|
276
|
-
|
|
335
|
+
const zodIssues = zodErrorIssues(error);
|
|
336
|
+
// A ZodError's message is its serialised issue list; show the issue messages instead.
|
|
337
|
+
if (zodIssues)
|
|
338
|
+
message = zodIssues.map((issue) => `${issue.path?.length ? `${issue.path.map(String).join('.')}: ` : ''}${singleLine(String(issue.message))}`).join(' ') || 'Invalid argument.';
|
|
339
|
+
// Server bodies can contain credentials, customer data, or arbitrary error text, so an error that
|
|
340
|
+
// carries one shows only text the CLI wrote, or the reason the public client accepted.
|
|
277
341
|
if (error && typeof error === 'object' && 'body' in error && error.body != null && 'status' in error && typeof error.status === 'number') {
|
|
278
342
|
if ('code' in error && error.code === 'FLAREHAWK_VIEW_AS_REQUIRED') {
|
|
279
343
|
message = 'Select a customer with --customer-id <customer UUID> for security commands.';
|
|
@@ -281,13 +345,16 @@ export function formatError(error, mode, writer) {
|
|
|
281
345
|
else {
|
|
282
346
|
// Only the reason the public client accepted from a known problem+json error is shown.
|
|
283
347
|
reason = 'reason' in error && typeof error.reason === 'string' ? singleLine(error.reason) : '';
|
|
284
|
-
|
|
348
|
+
const isCliAuthored = 'isCliAuthored' in error && error.isCliAuthored === true;
|
|
349
|
+
message = reason || (isCliAuthored ? message : `Request failed (HTTP ${error.status}).`);
|
|
285
350
|
}
|
|
286
351
|
}
|
|
287
352
|
const payload = {
|
|
288
353
|
success: false,
|
|
289
354
|
error: message,
|
|
290
355
|
};
|
|
356
|
+
if (zodIssues)
|
|
357
|
+
payload.code = 'INVALID_ARGUMENT';
|
|
291
358
|
if (error && typeof error === 'object') {
|
|
292
359
|
if ('status' in error && typeof error.status === 'number' &&
|
|
293
360
|
Number.isInteger(error.status) && error.status >= 0 && error.status <= 599) {
|
|
@@ -310,14 +377,22 @@ export function formatError(error, mode, writer) {
|
|
|
310
377
|
if (hint)
|
|
311
378
|
payload.hint = hint;
|
|
312
379
|
}
|
|
380
|
+
// The One tool run a failed scan recorded, for `vigilbase runs get`.
|
|
381
|
+
if ('toolRunId' in error && typeof error.toolRunId === 'string' && UUID.test(error.toolRunId)) {
|
|
382
|
+
payload.toolRunId = error.toolRunId;
|
|
383
|
+
}
|
|
313
384
|
}
|
|
314
385
|
if (mode === 'json') {
|
|
315
386
|
printJson(writer, payload);
|
|
316
387
|
return;
|
|
317
388
|
}
|
|
318
|
-
|
|
389
|
+
// Local checkup codes show as in the terminal UI; invalid-argument messages already name the flag.
|
|
390
|
+
const isLocalCode = LOCAL_ERROR_CODES.includes(payload.code);
|
|
391
|
+
writer.stderr(`Error: ${message}${(reason || isLocalCode) && typeof payload.code === 'string' ? ` (${payload.code})` : ''}`);
|
|
319
392
|
if (typeof payload.retryAfterSec === 'number') {
|
|
320
|
-
|
|
393
|
+
// Quotas reset after up to an hour: minutes read better than thousands of seconds.
|
|
394
|
+
const seconds = payload.retryAfterSec;
|
|
395
|
+
writer.stderr(seconds < 120 ? `Retry after ${seconds}s` : `Retry after about ${Math.round(seconds / 60)} minutes`);
|
|
321
396
|
}
|
|
322
397
|
if (typeof payload.hint === 'string') {
|
|
323
398
|
writer.stderr(`Hint: ${payload.hint}`);
|
package/dist/lib/help.js
CHANGED
|
@@ -19,6 +19,52 @@ export function isCatalogTool(name) {
|
|
|
19
19
|
}
|
|
20
20
|
/** Column where descriptions start in the main help: two-space indent, term, at least two spaces. */
|
|
21
21
|
const DESCRIPTION_COLUMN = 31;
|
|
22
|
+
/** Help lines fit an 80-column terminal. */
|
|
23
|
+
const HELP_WIDTH = 80;
|
|
24
|
+
/**
|
|
25
|
+
* Where a wrapped line continues: under the description of a row, under the text of a usage
|
|
26
|
+
* line, at the indent of a note, or at the margin for prose.
|
|
27
|
+
*/
|
|
28
|
+
function continuationIndent(line) {
|
|
29
|
+
const described = /^ {2}\S.*? {2,}(?=\S)/.exec(line);
|
|
30
|
+
if (described)
|
|
31
|
+
return described[0].length;
|
|
32
|
+
if (line.startsWith('Usage: '))
|
|
33
|
+
return 'Usage: '.length;
|
|
34
|
+
const indent = /^ */.exec(line)[0].length;
|
|
35
|
+
return indent === 0 ? 0 : Math.max(indent, 4);
|
|
36
|
+
}
|
|
37
|
+
/** Where to cut a line at most `width` long: before an option group, else at a space, else after a |. */
|
|
38
|
+
function breakPoint(line, from, width) {
|
|
39
|
+
const head = line.slice(0, width + 1);
|
|
40
|
+
const bracket = head.lastIndexOf(' [');
|
|
41
|
+
if (bracket > from + (width - from) / 2)
|
|
42
|
+
return { cut: bracket, skip: 1 };
|
|
43
|
+
const space = head.lastIndexOf(' ');
|
|
44
|
+
if (space > from)
|
|
45
|
+
return { cut: space, skip: 1 };
|
|
46
|
+
const bar = line.slice(0, width).lastIndexOf('|');
|
|
47
|
+
return bar > from ? { cut: bar + 1, skip: 0 } : null;
|
|
48
|
+
}
|
|
49
|
+
/** Wraps help lines longer than HELP_WIDTH, keeping descriptions aligned. */
|
|
50
|
+
function wrapHelp(text) {
|
|
51
|
+
return text.split('\n').flatMap((line) => {
|
|
52
|
+
const lines = [];
|
|
53
|
+
const pad = ' '.repeat(continuationIndent(line));
|
|
54
|
+
let rest = line;
|
|
55
|
+
// Past the first cut, only cut after the indent, so every line is shorter than the last.
|
|
56
|
+
let from = /^ */.exec(line)[0].length;
|
|
57
|
+
while (rest.length > HELP_WIDTH) {
|
|
58
|
+
const at = breakPoint(rest, from, HELP_WIDTH);
|
|
59
|
+
if (!at)
|
|
60
|
+
break;
|
|
61
|
+
lines.push(rest.slice(0, at.cut).trimEnd());
|
|
62
|
+
rest = `${pad}${rest.slice(at.cut + at.skip).trimStart()}`;
|
|
63
|
+
from = pad.length;
|
|
64
|
+
}
|
|
65
|
+
return [...lines, rest];
|
|
66
|
+
}).join('\n');
|
|
67
|
+
}
|
|
22
68
|
/** Two-column help rows whose descriptions all start at DESCRIPTION_COLUMN. */
|
|
23
69
|
function helpRows(rows) {
|
|
24
70
|
return rows
|
|
@@ -35,7 +81,7 @@ const GLOBAL_OPTIONS = helpRows([
|
|
|
35
81
|
['--auth-url <origin>', 'Identity issuer (default: auth.vigilbase.com)'],
|
|
36
82
|
['--client-id <id>', 'Device authorization client ID'],
|
|
37
83
|
]);
|
|
38
|
-
export const HELP_TEXT = `Usage: vigilbase <command>
|
|
84
|
+
export const HELP_TEXT = wrapHelp(`Usage: vigilbase <command>
|
|
39
85
|
|
|
40
86
|
Public tools (no login)
|
|
41
87
|
list List available tools
|
|
@@ -63,7 +109,7 @@ Account (login required)
|
|
|
63
109
|
cf <command> Cloudflare accounts, zones, DNS, WAF, analytics
|
|
64
110
|
security <command> Incidents, logs, assets, detections, intel, reports
|
|
65
111
|
watchdog [list|scans] Watchdog targets / scans
|
|
66
|
-
customers <command>
|
|
112
|
+
customers <command> Customers, billing, reports, investigations
|
|
67
113
|
orgs <command> Organizations, members, permission roles
|
|
68
114
|
monitoring <command> Health, probes, metrics, customer targets
|
|
69
115
|
support tickets Support ticket history
|
|
@@ -81,7 +127,7 @@ Examples
|
|
|
81
127
|
vigilbase runs list --json
|
|
82
128
|
vigilbase cf zones
|
|
83
129
|
vigilbase security incidents --severity high
|
|
84
|
-
|
|
130
|
+
`);
|
|
85
131
|
/** How to run a catalog tool: `help <tool>` and `<tool> --help`, since tools run as `run <tool> <target>`. */
|
|
86
132
|
function helpForTool(tool) {
|
|
87
133
|
if (isPublicTool(tool)) {
|
|
@@ -100,6 +146,10 @@ Details: vigilbase describe ${tool}`;
|
|
|
100
146
|
}
|
|
101
147
|
export const HELP_USAGE = 'Usage: vigilbase help [<command> | <tool>]';
|
|
102
148
|
export function helpForCommand(command) {
|
|
149
|
+
const help = commandHelp(command);
|
|
150
|
+
return help === null ? null : wrapHelp(help);
|
|
151
|
+
}
|
|
152
|
+
function commandHelp(command) {
|
|
103
153
|
switch (command) {
|
|
104
154
|
case 'help':
|
|
105
155
|
return `${HELP_USAGE}
|
|
@@ -127,13 +177,14 @@ Run vigilbase help <tool> for a tool's usage.`;
|
|
|
127
177
|
|
|
128
178
|
Checks the public tools API, your session (Identity's userinfo endpoint when
|
|
129
179
|
signed in through Identity, otherwise One's session endpoint) and any saved
|
|
130
|
-
sign-in independently. Login is optional. Exits with status 1 if any check
|
|
131
|
-
Use --base-url, --one-url, --auth-url and --timeout to check other
|
|
180
|
+
sign-in independently. Login is optional. Exits with status 1 if any check
|
|
181
|
+
fails. Use --base-url, --one-url, --auth-url and --timeout to check other
|
|
182
|
+
environments.`;
|
|
132
183
|
case 'account':
|
|
133
184
|
return `Usage: vigilbase account [services|org|endpoints|sessions|invites|billing] [--json]
|
|
134
185
|
|
|
135
186
|
Requires \`vigilbase login\`.
|
|
136
|
-
account invites create
|
|
187
|
+
account invites create <email>|--email <email> [--role <role>] --confirm`;
|
|
137
188
|
case 'runhistory':
|
|
138
189
|
case 'runs':
|
|
139
190
|
return `Usage: vigilbase runs [list|get <UUID>] [--json]
|
package/dist/lib/network.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type { CliConfig } from './config.js';
|
|
2
2
|
export declare class NetworkError extends Error {
|
|
3
3
|
readonly code: string;
|
|
4
|
+
readonly hint?: string | undefined;
|
|
4
5
|
readonly status = 0;
|
|
5
|
-
|
|
6
|
+
/** `hint` is printed after the error; doctor, which is the connectivity check, leaves it out. */
|
|
7
|
+
constructor(message: string, code: string, cause?: unknown, hint?: string | undefined);
|
|
6
8
|
}
|
|
7
9
|
export declare function networkError(error: unknown, url: string, timeoutMs: number, timedOut: boolean): NetworkError;
|
|
10
|
+
/** The option that sets each service's origin, which a caller names for the redirect message. */
|
|
11
|
+
export type OriginOption = '--base-url' | '--one-url' | '--auth-url';
|
|
8
12
|
export declare const MAX_RESPONSE_BYTES: number;
|
|
9
13
|
/** Bound decoded bytes even when Content-Length is absent, compressed, or dishonest. */
|
|
10
14
|
export declare function readBoundedBody(response: Response, signal: AbortSignal, maxBytes?: number): Promise<string>;
|
|
@@ -18,8 +22,13 @@ export declare function anySignal(signals: AbortSignal[]): {
|
|
|
18
22
|
signal: AbortSignal;
|
|
19
23
|
dispose: () => void;
|
|
20
24
|
};
|
|
21
|
-
/**
|
|
22
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Keep the timeout active until the complete response body has been read. `option` names the option that sets the
|
|
27
|
+
* service's origin, for the message when it redirects.
|
|
28
|
+
*/
|
|
29
|
+
export declare function requestText(config: CliConfig, url: string, { option, ...init }?: RequestInit & {
|
|
30
|
+
option?: OriginOption;
|
|
31
|
+
}): Promise<{
|
|
23
32
|
response: Response;
|
|
24
33
|
text: string;
|
|
25
34
|
}>;
|
package/dist/lib/network.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { isSystemTrustConfigured } from './system-trust.js';
|
|
1
2
|
export class NetworkError extends Error {
|
|
2
3
|
code;
|
|
4
|
+
hint;
|
|
3
5
|
status = 0;
|
|
4
|
-
|
|
6
|
+
/** `hint` is printed after the error; doctor, which is the connectivity check, leaves it out. */
|
|
7
|
+
constructor(message, code, cause, hint) {
|
|
5
8
|
super(message, { cause });
|
|
6
9
|
this.code = code;
|
|
10
|
+
this.hint = hint;
|
|
7
11
|
this.name = 'NetworkError';
|
|
8
12
|
}
|
|
9
13
|
}
|
|
@@ -18,21 +22,70 @@ function errorCodes(error, seen = new Set()) {
|
|
|
18
22
|
...(Array.isArray(item.errors) ? item.errors.flatMap((e) => errorCodes(e, seen)) : []),
|
|
19
23
|
];
|
|
20
24
|
}
|
|
25
|
+
/** Messages along the cause chain: undici reports a blocked port or a refused redirect only by message. */
|
|
26
|
+
function causeMessages(error, seen = new Set()) {
|
|
27
|
+
if (!error || typeof error !== 'object' || seen.has(error))
|
|
28
|
+
return [];
|
|
29
|
+
seen.add(error);
|
|
30
|
+
const item = error;
|
|
31
|
+
return [...(typeof item.message === 'string' ? [item.message] : []), ...causeMessages(item.cause, seen)];
|
|
32
|
+
}
|
|
21
33
|
export function networkError(error, url, timeoutMs, timedOut) {
|
|
22
34
|
// Never echo query strings, URL credentials, request bodies, or raw causes.
|
|
23
|
-
const endpoint = new URL(url)
|
|
35
|
+
const { origin: endpoint, port } = new URL(url);
|
|
24
36
|
const codes = errorCodes(error);
|
|
25
37
|
const code = codes[0] ?? 'NETWORK_ERROR';
|
|
38
|
+
const messages = causeMessages(error);
|
|
26
39
|
if (timedOut || codes.some((c) => /TIMEOUT|ETIMEDOUT/.test(c))) {
|
|
27
40
|
return new NetworkError(`Request to ${endpoint} timed out after ${timeoutMs}ms. Check your connection or increase --timeout.`, 'TIMEOUT', error);
|
|
28
41
|
}
|
|
42
|
+
if (messages.includes('bad port')) {
|
|
43
|
+
return new NetworkError(`Node's fetch refuses port ${port} of ${endpoint} (a port the Fetch standard blocks). Use another port.`, 'BLOCKED_PORT', error);
|
|
44
|
+
}
|
|
45
|
+
if (messages.includes('unexpected redirect')) {
|
|
46
|
+
return new NetworkError(`${endpoint} answered with a redirect, which the CLI does not follow.`, 'UNEXPECTED_REDIRECT', error);
|
|
47
|
+
}
|
|
29
48
|
if (codes.some((c) => /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(c))) {
|
|
30
|
-
|
|
49
|
+
// Without system trust (older Node), the OS certificate store isn't used at all.
|
|
50
|
+
const upgrade = isSystemTrustConfigured() ? '' : ' Use Node 22.19+ or Node 24.5+ for automatic system certificate trust.';
|
|
51
|
+
return new NetworkError(`TLS certificate verification failed for ${endpoint} (${code}).${upgrade} If your organization uses a private CA, configure NODE_EXTRA_CA_CERTS with its trusted PEM certificate.`, code, error);
|
|
31
52
|
}
|
|
32
53
|
if (codes.some((c) => c === 'ENOTFOUND' || c === 'EAI_AGAIN')) {
|
|
33
54
|
return new NetworkError(`Cannot resolve ${endpoint} (${code}). Check DNS, your VPN, and the configured API URL.`, code, error);
|
|
34
55
|
}
|
|
35
|
-
return new NetworkError(`Cannot connect to ${endpoint} (${code}). Check your connection, VPN, proxy settings, and API URL
|
|
56
|
+
return new NetworkError(`Cannot connect to ${endpoint} (${code}). Check your connection, VPN, proxy settings, and API URL.`, code, error, 'Run `vigilbase doctor` for connectivity checks.');
|
|
57
|
+
}
|
|
58
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
59
|
+
/** The option that sets this origin, when the caller didn't name it. */
|
|
60
|
+
function optionFor(config, origin) {
|
|
61
|
+
if (origin === config.publicBaseUrl)
|
|
62
|
+
return '--base-url';
|
|
63
|
+
if (origin === config.oneBaseUrl)
|
|
64
|
+
return '--one-url';
|
|
65
|
+
if (origin === config.identityBaseUrl)
|
|
66
|
+
return '--auth-url';
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/** A redirect the CLI refuses, naming only the target's origin: its path and query may carry tokens. */
|
|
70
|
+
function redirectError(config, url, response, named) {
|
|
71
|
+
const { origin } = new URL(url);
|
|
72
|
+
let target;
|
|
73
|
+
try {
|
|
74
|
+
const location = new URL(response.headers.get('location') ?? '', url);
|
|
75
|
+
if ((location.protocol === 'https:' || location.protocol === 'http:') && location.origin !== origin)
|
|
76
|
+
target = location.origin;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// An unparsable Location: say only that it redirected.
|
|
80
|
+
}
|
|
81
|
+
// Public, One and Identity may share an origin, so the caller's service decides the option.
|
|
82
|
+
const option = named ?? optionFor(config, origin);
|
|
83
|
+
const message = !target
|
|
84
|
+
? `${origin} answered with a redirect (HTTP ${response.status}), which the CLI does not follow.`
|
|
85
|
+
: option
|
|
86
|
+
? `${origin} redirected (HTTP ${response.status}) to ${target}. Set ${option} to that origin.`
|
|
87
|
+
: `${origin} redirected (HTTP ${response.status}) to ${target}, which the CLI does not follow.`;
|
|
88
|
+
return new NetworkError(message, 'UNEXPECTED_REDIRECT');
|
|
36
89
|
}
|
|
37
90
|
export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
38
91
|
/** Bound decoded bytes even when Content-Length is absent, compressed, or dishonest. */
|
|
@@ -97,22 +150,33 @@ export function anySignal(signals) {
|
|
|
97
150
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
98
151
|
return { signal: controller.signal, dispose };
|
|
99
152
|
}
|
|
100
|
-
/**
|
|
101
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Keep the timeout active until the complete response body has been read. `option` names the option that sets the
|
|
155
|
+
* service's origin, for the message when it redirects.
|
|
156
|
+
*/
|
|
157
|
+
export async function requestText(config, url, { option, ...init } = {}) {
|
|
102
158
|
const controller = new AbortController();
|
|
103
159
|
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
104
160
|
const { signal, dispose } = init.signal
|
|
105
161
|
? anySignal([init.signal, controller.signal])
|
|
106
162
|
: { signal: controller.signal, dispose: () => undefined };
|
|
107
163
|
try {
|
|
108
|
-
// Never
|
|
109
|
-
// 'manual' is
|
|
110
|
-
const response = await fetch(url, { ...init, signal, redirect:
|
|
164
|
+
// Never follow redirects: they could carry token-bearing POST bodies (or credentials) elsewhere.
|
|
165
|
+
// 'manual' surfaces the 3xx, which is refused here unless the caller asked for it.
|
|
166
|
+
const response = await fetch(url, { ...init, signal, redirect: 'manual' });
|
|
167
|
+
if (init.redirect !== 'manual' && REDIRECT_STATUSES.has(response.status) && response.headers.has('location')) {
|
|
168
|
+
await response.body?.cancel().catch(() => undefined);
|
|
169
|
+
throw redirectError(config, url, response, option);
|
|
170
|
+
}
|
|
111
171
|
return { response, text: await readBoundedBody(response, signal) };
|
|
112
172
|
}
|
|
113
173
|
catch (error) {
|
|
114
174
|
if (error instanceof NetworkError)
|
|
115
175
|
throw error;
|
|
176
|
+
// Fetch refuses a header value (a token with a newline) before sending anything. Its message holds the value.
|
|
177
|
+
if (error instanceof TypeError && error.cause === undefined && /header/i.test(error.message)) {
|
|
178
|
+
throw Object.assign(new Error('A request header contains characters HTTP does not allow, so nothing was sent.'), { code: 'INVALID_ARGUMENT' });
|
|
179
|
+
}
|
|
116
180
|
if (init.signal?.aborted) {
|
|
117
181
|
throw new NetworkError('Request cancelled.', 'CANCELLED', error);
|
|
118
182
|
}
|
package/dist/lib/one-client.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type { CliConfig } from './config.js';
|
|
|
7
7
|
export type OneToolExecuteResponse = {
|
|
8
8
|
success: boolean;
|
|
9
9
|
error?: string;
|
|
10
|
+
/** `scan_unassessed`: the scanner ran but could not assess the target. */
|
|
11
|
+
code?: string;
|
|
10
12
|
data?: {
|
|
11
13
|
toolRunId?: string;
|
|
12
14
|
status?: string;
|