@vigilbase/cli 0.2.1 → 0.2.3

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.
@@ -1,3 +1,6 @@
1
1
  export declare const HELP_TAGLINE = "one \u00B7 security \u00B7 tools";
2
+ /** A tool from the catalog the CLI knows offline: it runs as `run <tool> <target>`, not as a command. */
3
+ export declare function isCatalogTool(name: string): boolean;
2
4
  export declare const HELP_TEXT: string;
5
+ export declare const HELP_USAGE = "Usage: vigilbase help [<command> | <tool>]";
3
6
  export declare function helpForCommand(command: string): string | null;
package/dist/lib/help.js CHANGED
@@ -2,7 +2,21 @@ import { CLOUDFLARE_HELP } from '../commands/cloudflare.js';
2
2
  import { LOCAL_HELP } from '../commands/local.js';
3
3
  import { ONE_HELP } from '../commands/one.js';
4
4
  import { SECURITY_HELP } from '../commands/security.js';
5
+ import { isOneAuthRequiredTool, ONE_AUTH_REQUIRED_TOOLS } from './one-client.js';
5
6
  export const HELP_TAGLINE = 'one · security · tools';
7
+ /** vigilbase.com's public catalog, with each tool's shortcut command. `help <tool>` answers offline. */
8
+ const PUBLIC_TOOL_SHORTCUTS = {
9
+ 'security-headers': 'headers <url>',
10
+ 'email-security': 'email <domain>',
11
+ };
12
+ const PUBLIC_TOOLS = Object.keys(PUBLIC_TOOL_SHORTCUTS);
13
+ function isPublicTool(name) {
14
+ return Object.hasOwn(PUBLIC_TOOL_SHORTCUTS, name);
15
+ }
16
+ /** A tool from the catalog the CLI knows offline: it runs as `run <tool> <target>`, not as a command. */
17
+ export function isCatalogTool(name) {
18
+ return isPublicTool(name) || isOneAuthRequiredTool(name);
19
+ }
6
20
  /** Column where descriptions start in the main help: two-space indent, term, at least two spaces. */
7
21
  const DESCRIPTION_COLUMN = 31;
8
22
  /** Two-column help rows whose descriptions all start at DESCRIPTION_COLUMN. */
@@ -32,7 +46,7 @@ Public tools (no login)
32
46
  badge <domain> · doctor Badge URL · connectivity
33
47
 
34
48
  Auth-required tools (after login)
35
- website-security · react2shell · fortibleed-check
49
+ ${ONE_AUTH_REQUIRED_TOOLS.map((tool) => ` run ${tool} <target>`).join('\n')}
36
50
 
37
51
  On this machine (no login)
38
52
  cloudflare-checkup Run the Cloudflare Checkup on this machine in one
@@ -57,7 +71,7 @@ Account (login required)
57
71
  Options
58
72
  ${GLOBAL_OPTIONS}
59
73
 
60
- Use vigilbase <command> --help for command options.
74
+ Use vigilbase <command> --help or vigilbase help <tool> for usage.
61
75
 
62
76
  Examples
63
77
  vigilbase headers https://vigilbase.com
@@ -68,16 +82,40 @@ Examples
68
82
  vigilbase cf zones
69
83
  vigilbase security incidents --severity high
70
84
  `;
85
+ /** How to run a catalog tool: `help <tool>` and `<tool> --help`, since tools run as `run <tool> <target>`. */
86
+ function helpForTool(tool) {
87
+ if (isPublicTool(tool)) {
88
+ return `Usage: vigilbase run ${tool} <target> [--json] [--via public|one] [--public]
89
+
90
+ Public tool, no login needed. Shortcut: vigilbase ${PUBLIC_TOOL_SHORTCUTS[tool]}
91
+ Details: vigilbase describe ${tool}`;
92
+ }
93
+ if (isOneAuthRequiredTool(tool)) {
94
+ return `Usage: vigilbase run ${tool} <target> [--json]
95
+
96
+ Runs through Vigilbase One: run \`vigilbase login\` first.
97
+ Details: vigilbase describe ${tool}`;
98
+ }
99
+ return null;
100
+ }
101
+ export const HELP_USAGE = 'Usage: vigilbase help [<command> | <tool>]';
71
102
  export function helpForCommand(command) {
72
103
  switch (command) {
104
+ case 'help':
105
+ return `${HELP_USAGE}
106
+
107
+ Shows the usage of a command or of a tool, for example vigilbase help run or
108
+ vigilbase help website-security. Without one, shows every command.`;
73
109
  case 'tools':
74
110
  case 'list':
75
111
  return `Usage: vigilbase list [--json]`;
76
112
  case 'run':
77
113
  return `Usage: vigilbase run <tool> <target> [--json] [--via public|one] [--public]
78
114
 
79
- Public: security-headers, email-security
80
- Login: website-security, react2shell, fortibleed-check`;
115
+ Public: ${PUBLIC_TOOLS.join(', ')}
116
+ Login: ${ONE_AUTH_REQUIRED_TOOLS.join(', ')}
117
+
118
+ Run vigilbase help <tool> for a tool's usage.`;
81
119
  case 'describe':
82
120
  return `Usage: vigilbase describe <tool> [--json] [--no-skill]`;
83
121
  case 'skill':
@@ -131,6 +169,6 @@ Prefer --token-stdin with piped input to keep tokens out of command arguments.`;
131
169
  case 'email':
132
170
  return `Usage: vigilbase email <domain> [--json] [--via public|one] [--public]`;
133
171
  default:
134
- return null;
172
+ return helpForTool(command);
135
173
  }
136
174
  }
@@ -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;
@@ -54,6 +56,8 @@ export declare class VigilbaseOneClient {
54
56
  /** Authenticated JSON transport shared by the One command clients. */
55
57
  requestJson<T extends ApiErrorBody = Record<string, unknown>>(path: string, init?: RequestInit, options?: {
56
58
  allowStatuses?: number[];
59
+ /** Whether the request names a customer outside the query string (a JSON body). */
60
+ hasCustomerScope?: boolean;
57
61
  }): Promise<T>;
58
62
  getSession(): Promise<OneSessionResponse>;
59
63
  getAccount(): Promise<Record<string, unknown>>;
@@ -6,6 +6,8 @@ import { z } from 'zod';
6
6
  import { validateOneOrigin } from './config.js';
7
7
  import { validateStripePortalUrl } from './open-url.js';
8
8
  import { parseJsonResponse, requestText } from './network.js';
9
+ import { oneErrorHint } from './one-error-hints.js';
10
+ import { SCAN_FAILED_MESSAGE } from './format.js';
9
11
  import { retryAfterSeconds, VigilbaseApiError } from './client.js';
10
12
  /**
11
13
  * Tools that require Vigilbase One authentication (auth-required scanners).
@@ -129,9 +131,15 @@ export class VigilbaseOneClient {
129
131
  if (errorDetails.code === 'FLAREHAWK_VIEW_AS_REQUIRED') {
130
132
  message = 'Select a customer with --customer-id <customer UUID> for security commands.';
131
133
  }
134
+ const hint = response.ok ? undefined : oneErrorHint({
135
+ method: init?.method ?? 'GET', path, status: response.status, code: errorDetails.code, hasCustomerScope: options?.hasCustomerScope,
136
+ });
137
+ // Every message below is the CLI's own; One's error text stays in the body, which is never printed.
132
138
  if (response.status === 401 || response.status === 403) {
133
- throw new VigilbaseApiError(message ||
134
- 'Authentication required. Run `vigilbase login`.', { status: response.status, body: parsed, code: errorDetails.code });
139
+ throw new VigilbaseApiError(message || (response.status === 401
140
+ ? 'Authentication required. Run `vigilbase login`.'
141
+ // Signed in, but One refuses this user or this request: logging in again won't help.
142
+ : "You don't have access to this."), { status: response.status, body: parsed, code: errorDetails.code, hint, isCliAuthored: true });
135
143
  }
136
144
  if (response.status === 429) {
137
145
  throw new VigilbaseApiError(message || 'Rate limit exceeded.', {
@@ -139,18 +147,21 @@ export class VigilbaseOneClient {
139
147
  body: parsed,
140
148
  retryAfterSec: retryAfterSeconds(response, body),
141
149
  code: errorDetails.code,
150
+ isCliAuthored: true,
142
151
  });
143
152
  }
144
153
  const allowed = options?.allowStatuses ?? [];
145
154
  if (!response.ok && !allowed.includes(response.status)) {
146
155
  throw new VigilbaseApiError(message ||
147
- `Request failed (HTTP ${response.status})`, { status: response.status, body: parsed, code: errorDetails.code });
156
+ `Request failed (HTTP ${response.status}).`, { status: response.status, body: parsed, code: errorDetails.code, hint, isCliAuthored: true });
148
157
  }
149
158
  if (!z.record(z.string(), z.unknown()).safeParse(body).success) {
150
159
  throw new VigilbaseApiError('Invalid One API response. Expected a JSON object.', { status: response.status });
151
160
  }
152
161
  if (parsed.success === false || ('ok' in parsed && parsed.ok === false)) {
153
- throw new VigilbaseApiError('One API reported an unsuccessful operation.', { status: response.status, body: parsed, code: errorDetails.code });
162
+ throw new VigilbaseApiError('One API reported an unsuccessful operation.', {
163
+ status: response.status, body: parsed, code: errorDetails.code, isCliAuthored: true,
164
+ });
154
165
  }
155
166
  return parsed;
156
167
  }
@@ -184,7 +195,7 @@ export class VigilbaseOneClient {
184
195
  }
185
196
  const { response, body } = await this.request('/api/auth/get-session');
186
197
  if (!response.ok) {
187
- throw new VigilbaseApiError(`Failed to load One session (HTTP ${response.status})`, { status: response.status, body });
198
+ throw new VigilbaseApiError(`Failed to load One session (HTTP ${response.status})`, { status: response.status, body, isCliAuthored: true });
188
199
  }
189
200
  const schema = z.object({
190
201
  session: z.record(z.string(), z.unknown()).nullable().optional(),
@@ -279,10 +290,70 @@ export class VigilbaseOneClient {
279
290
  return this.requestJson(`/api/tool-runs/${encodeURIComponent(id)}`);
280
291
  }
281
292
  async executeTool(toolName, target) {
282
- return this.requestJson(`/api/tools/${encodeURIComponent(toolName)}/execute`, {
283
- method: 'POST',
284
- headers: { 'Content-Type': 'application/json' },
285
- body: JSON.stringify({ target }),
286
- }, { allowStatuses: [422] });
293
+ try {
294
+ return await this.requestJson(`/api/tools/${encodeURIComponent(toolName)}/execute`, {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({ target }),
298
+ }, { allowStatuses: [422] });
299
+ }
300
+ catch (error) {
301
+ const unassessed = unassessedScan(error);
302
+ if (unassessed)
303
+ return unassessed;
304
+ throw failedScanError(error) ?? error;
305
+ }
287
306
  }
288
307
  }
308
+ /** One's answer when the scan itself failed, for example when com ran out of memory. */
309
+ const failedScanSchema = z.object({
310
+ success: z.literal(false),
311
+ data: z.object({ status: z.literal('failed'), toolRunId: z.unknown().optional() }).passthrough(),
312
+ }).passthrough();
313
+ const unassessedScanSchema = z.object({
314
+ code: z.literal('scan_unassessed'),
315
+ data: z.object({
316
+ toolRunId: z.unknown().optional(),
317
+ result: z.object({ status: z.literal('error'), message: z.string() }).passthrough(),
318
+ }),
319
+ });
320
+ /**
321
+ * A scan the scanner ran but could not assess (a bot verification challenge, an
322
+ * error page). The result carries the scanner's message, which the formatter
323
+ * prints bounded like any scanner result; One's error text stays unused.
324
+ */
325
+ function unassessedScan(error) {
326
+ if (!(error instanceof VigilbaseApiError) || error.status !== 422)
327
+ return undefined;
328
+ const unassessed = unassessedScanSchema.safeParse(error.body);
329
+ if (!unassessed.success)
330
+ return undefined;
331
+ const toolRunId = z.string().uuid().safeParse(unassessed.data.data.toolRunId);
332
+ return {
333
+ success: false,
334
+ code: 'scan_unassessed',
335
+ data: {
336
+ toolRunId: toolRunId.success ? toolRunId.data : undefined,
337
+ status: 'failed',
338
+ grade: null,
339
+ gradeScore: null,
340
+ result: unassessed.data.data.result,
341
+ },
342
+ };
343
+ }
344
+ /** com's copy for a 422 failed scan, with the run One recorded; One's error text stays hidden. */
345
+ function failedScanError(error) {
346
+ if (!(error instanceof VigilbaseApiError) || error.status !== 422)
347
+ return undefined;
348
+ const failed = failedScanSchema.safeParse(error.body);
349
+ if (!failed.success)
350
+ return undefined;
351
+ const toolRunId = z.string().uuid().safeParse(failed.data.data.toolRunId);
352
+ return new VigilbaseApiError(SCAN_FAILED_MESSAGE, {
353
+ status: 422,
354
+ body: error.body,
355
+ code: error.code,
356
+ isCliAuthored: true,
357
+ toolRunId: toolRunId.success ? toolRunId.data : undefined,
358
+ });
359
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * CLI-authored hints for One errors the CLI recognises. They are chosen only
3
+ * from the request (method, route, whether a customer was selected), the HTTP
4
+ * status and One's own error codes. One's free-text messages are never shown:
5
+ * they can describe its configuration.
6
+ */
7
+ export declare const STAFF_CUSTOMER_SCOPE_HINT = "Vigilbase staff: add --customer-id <uuid>.";
8
+ export declare const PERSONAL_SETTINGS_HINT = "Personal settings belong to each user, so View-as (--customer-id) cannot read them.";
9
+ export declare const BROWSER_SESSION_HINT = "Invitations need a One browser session: manage them at https://one.vigilbase.com.";
10
+ export type OneErrorContext = {
11
+ method: string;
12
+ /** The One path the CLI requested, with its query string. */
13
+ path: string;
14
+ status: number;
15
+ code?: string;
16
+ /** Whether the request named a customer; defaults to a `customerId` query parameter. */
17
+ hasCustomerScope?: boolean;
18
+ };
19
+ export declare function oneErrorHint(context: OneErrorContext): string | undefined;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CLI-authored hints for One errors the CLI recognises. They are chosen only
3
+ * from the request (method, route, whether a customer was selected), the HTTP
4
+ * status and One's own error codes. One's free-text messages are never shown:
5
+ * they can describe its configuration.
6
+ */
7
+ export const STAFF_CUSTOMER_SCOPE_HINT = 'Vigilbase staff: add --customer-id <uuid>.';
8
+ export const PERSONAL_SETTINGS_HINT = 'Personal settings belong to each user, so View-as (--customer-id) cannot read them.';
9
+ export const BROWSER_SESSION_HINT = 'Invitations need a One browser session: manage them at https://one.vigilbase.com.';
10
+ /** One's security boundary codes that already explain a 403 (apps/one/src/lib/flarehawk/access.ts). */
11
+ const SECURITY_ACCESS_CODES = new Set([
12
+ 'FLAREHAWK_CUSTOMER_ACCESS_DENIED',
13
+ 'FLAREHAWK_ROLE_UNSUPPORTED',
14
+ 'FLAREHAWK_ROLE_INSUFFICIENT',
15
+ 'FLAREHAWK_ORGANIZATION_REQUIRED',
16
+ 'FLAREHAWK_VIEW_AS_REQUIRED',
17
+ ]);
18
+ const RULES = [
19
+ // Staff sessions must name the customer; One answers 400 without one.
20
+ { method: 'GET', route: /^\/api\/support\/tickets$/u, status: 400, hasCustomerScope: false, hint: STAFF_CUSTOMER_SCOPE_HINT },
21
+ { method: 'GET', route: /^\/api\/services\/cloudflare\/checkups$/u, status: 400, hasCustomerScope: false, hint: STAFF_CUSTOMER_SCOPE_HINT },
22
+ { method: 'POST', route: /^\/api\/services\/cloudflare\/checkups\/start$/u, status: 400, hasCustomerScope: false, hint: STAFF_CUSTOMER_SCOPE_HINT },
23
+ { method: 'GET', route: /^\/api\/services\/cloudflare\/workspace(?:\/[a-z-]+)?$/u, status: 400, hasCustomerScope: false, hint: STAFF_CUSTOMER_SCOPE_HINT },
24
+ // Personal security settings are per user; a View-as read is refused.
25
+ {
26
+ method: 'GET',
27
+ route: /^\/api\/flarehawk\/(?:settings\/(?:lifecycle-emails|broadcast-emails)|integrations\/grants|notifications\/preferences)$/u,
28
+ status: 403,
29
+ hasCustomerScope: true,
30
+ hint: PERSONAL_SETTINGS_HINT,
31
+ excludedCodes: SECURITY_ACCESS_CODES,
32
+ },
33
+ // Identity's management API accepts only One browser sessions, so CLI tokens can't read or send invitations.
34
+ { method: 'GET', route: /^\/api\/auth\/orgs\/invitations$/u, status: 403, hasCustomerScope: false, hint: BROWSER_SESSION_HINT, code: 'browser_session_required' },
35
+ { method: 'POST', route: /^\/api\/auth\/orgs\/invite$/u, status: 403, hasCustomerScope: false, hint: BROWSER_SESSION_HINT, code: 'browser_session_required' },
36
+ ];
37
+ export function oneErrorHint(context) {
38
+ const [pathname = '', query = ''] = context.path.split('?', 2);
39
+ const hasCustomerScope = context.hasCustomerScope ?? new URLSearchParams(query).has('customerId');
40
+ const method = context.method.toUpperCase();
41
+ return RULES.find((rule) => rule.method === method &&
42
+ rule.status === context.status &&
43
+ rule.hasCustomerScope === hasCustomerScope &&
44
+ rule.route.test(pathname) &&
45
+ (rule.code === undefined || rule.code === context.code) &&
46
+ !(context.code !== undefined && rule.excludedCodes?.has(context.code)))?.hint;
47
+ }
@@ -10,6 +10,8 @@ export declare const LOCAL_ERROR_HINTS: Record<LocalCheckupErrorCode, string>;
10
10
  * hint names both ways out.
11
11
  */
12
12
  export declare const ENV_TOKEN_AUTH_HINT = "Set CLOUDFLARE_API_TOKEN in this shell to a valid read-only Cloudflare API token (read -rs CLOUDFLARE_API_TOKEN && export CLOUDFLARE_API_TOKEN) and run the same command again, or unset it (or use --cf-auth profile) to sign in with your browser instead.";
13
+ /** SNAPSHOT_INVALID for an `inspect` or `submit` path with no file: nothing to collect again, so how to make one. */
14
+ export declare const SNAPSHOT_MISSING_HINT = "Check the file path. To collect a snapshot into a file, run `npx @vigilbase/cli cloudflare-checkup collect --out <file>`.";
13
15
  /** CF_PERMISSION_DENIED in env mode: the token lacks a read permission; the browser sign-in is the other way out. */
14
16
  export declare const ENV_TOKEN_PERMISSION_HINT = "The API token in CLOUDFLARE_API_TOKEN cannot read everything the checkup needs. Add the read permissions to it, or unset it (or use --cf-auth profile) to sign in with your browser instead, then run the same command again.";
15
17
  export declare class LocalCheckupError extends Error {
@@ -75,6 +75,8 @@ export const LOCAL_ERROR_HINTS = {
75
75
  * hint names both ways out.
76
76
  */
77
77
  export const ENV_TOKEN_AUTH_HINT = `Set ${CF_TOKEN_ENV} in this shell to a valid read-only Cloudflare API token (read -rs ${CF_TOKEN_ENV} && export ${CF_TOKEN_ENV}) and run the same command again, or unset it (or use --cf-auth profile) to sign in with your browser instead.`;
78
+ /** SNAPSHOT_INVALID for an `inspect` or `submit` path with no file: nothing to collect again, so how to make one. */
79
+ export const SNAPSHOT_MISSING_HINT = 'Check the file path. To collect a snapshot into a file, run `npx @vigilbase/cli cloudflare-checkup collect --out <file>`.';
78
80
  /** CF_PERMISSION_DENIED in env mode: the token lacks a read permission; the browser sign-in is the other way out. */
79
81
  export const ENV_TOKEN_PERMISSION_HINT = `The API token in ${CF_TOKEN_ENV} cannot read everything the checkup needs. Add the read permissions to it, or unset it (or use --cf-auth profile) to sign in with your browser instead, then run the same command again.`;
80
82
  export class LocalCheckupError extends Error {
@@ -13,7 +13,7 @@ import { createHash, randomBytes } from 'node:crypto';
13
13
  import { link, open, readFile, rename, rm, stat } from 'node:fs/promises';
14
14
  import { basename, dirname, join } from 'node:path';
15
15
  import { CloudflareCheckupSnapshotV1Schema, SNAPSHOT_LIMITS, SNAPSHOT_SCHEMA_VERSION, expandRuleRuns, snapshotIssueList, snapshotRejectReason, } from './contract.js';
16
- import { LocalCheckupError } from './errors.js';
16
+ import { LocalCheckupError, SNAPSHOT_MISSING_HINT } from './errors.js';
17
17
  export function sha256Hex(bytes) {
18
18
  return createHash('sha256').update(bytes).digest('hex');
19
19
  }
@@ -90,8 +90,10 @@ export function snapshotParseError(issues) {
90
90
  /** Read, hash and validate a snapshot file against the vendored contract. */
91
91
  export async function loadSnapshotFile(path) {
92
92
  const info = await stat(path).catch(() => null);
93
- if (!info || !info.isFile())
94
- throw new LocalCheckupError('SNAPSHOT_INVALID', 'The snapshot file does not exist or is not a regular file.');
93
+ if (!info)
94
+ throw new LocalCheckupError('SNAPSHOT_INVALID', 'The snapshot file does not exist.', { hint: SNAPSHOT_MISSING_HINT });
95
+ if (!info.isFile())
96
+ throw new LocalCheckupError('SNAPSHOT_INVALID', 'The snapshot path is not a file.', { hint: SNAPSHOT_MISSING_HINT });
95
97
  if (info.size > SNAPSHOT_LIMITS.maxBytes) {
96
98
  throw new LocalCheckupError('SNAPSHOT_TOO_LARGE', `The snapshot is larger than the ${SNAPSHOT_LIMITS.maxBytes / 1024 / 1024} MiB contract limit.`);
97
99
  }
package/dist/types.d.ts CHANGED
@@ -53,6 +53,10 @@ export type AgentToolExecuteResponse = {
53
53
  error?: AgentApiError | string | null;
54
54
  uiUrl?: string;
55
55
  skillUrl?: string;
56
+ /** A One scan the scanner could not assess: One's status and code, and the run it recorded. */
57
+ status?: number;
58
+ code?: string;
59
+ toolRunId?: string;
56
60
  retryAfterSec?: number;
57
61
  toolsUrl?: string;
58
62
  skillsIndex?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vigilbase/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.",
5
5
  "keywords": [
6
6
  "vigilbase",