@vigilbase/cli 0.2.3 → 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 +111 -44
- package/LOCAL.md +99 -38
- package/ONE.md +1 -1
- package/README.md +215 -67
- package/dist/auth/acl.d.ts +6 -0
- package/dist/auth/acl.js +45 -0
- package/dist/auth/device-auth.js +1 -0
- 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 +52 -27
- 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.js +19 -1
- 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 +1 -1
- package/dist/lib/format.js +75 -24
- 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.js +9 -2
- 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 +2 -1
- package/esm-only.cjs +3 -0
- package/package.json +7 -4
package/dist/lib/client.js
CHANGED
|
@@ -82,6 +82,14 @@ function publicApiError(fallback, response, body) {
|
|
|
82
82
|
isCliAuthored: !reason,
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
86
|
+
/** com's 422 for a scan the scanner could not assess (apps/com/lib/agent/handlers.ts): it still carries the result. */
|
|
87
|
+
const unassessedScanSchema = z.object({
|
|
88
|
+
success: z.literal(false),
|
|
89
|
+
toolName: z.string(),
|
|
90
|
+
target: z.string(),
|
|
91
|
+
result: z.object({ status: z.literal('error') }).passthrough(),
|
|
92
|
+
});
|
|
85
93
|
/** The versioned public tools API. The unversioned /api/agent/tools aliases sunset on 2027-09-01. */
|
|
86
94
|
export const PUBLIC_TOOLS_PATH = '/api/v1/agent/tools';
|
|
87
95
|
/**
|
|
@@ -100,6 +108,7 @@ export class VigilbaseClient {
|
|
|
100
108
|
const url = `${this.origin}${path.startsWith('/') ? path : `/${path}`}`;
|
|
101
109
|
const { response, text } = await requestText(this.config, url, {
|
|
102
110
|
...init,
|
|
111
|
+
option: '--base-url',
|
|
103
112
|
headers: {
|
|
104
113
|
Accept: 'application/json',
|
|
105
114
|
'User-Agent': this.config.userAgent,
|
|
@@ -142,7 +151,16 @@ export class VigilbaseClient {
|
|
|
142
151
|
throw new VigilbaseApiError('Invalid tool execution response. Check the API URL or retry later.', { status: response.status });
|
|
143
152
|
}
|
|
144
153
|
if (!parsed.success) {
|
|
145
|
-
|
|
154
|
+
const unassessed = response.status === 422 ? unassessedScanSchema.safeParse(body) : null;
|
|
155
|
+
if (!unassessed?.success)
|
|
156
|
+
throw publicApiError(SCAN_FAILED_MESSAGE, response, body);
|
|
157
|
+
// A scan the scanner could not assess keeps its result, as One's route does.
|
|
158
|
+
const { toolName: name, target: scanned, result } = unassessed.data;
|
|
159
|
+
const toolRunId = typeof result.toolRunId === 'string' && UUID.test(result.toolRunId) ? result.toolRunId : undefined;
|
|
160
|
+
return {
|
|
161
|
+
success: false, toolName: name, target: scanned, grade: null, gradeScore: null, result,
|
|
162
|
+
error: null, status: 422, code: 'scan_unassessed', ...(toolRunId ? { toolRunId } : {}),
|
|
163
|
+
};
|
|
146
164
|
}
|
|
147
165
|
return parsed;
|
|
148
166
|
}
|
|
@@ -7,13 +7,13 @@ export declare const cloudflareScopeSchema: z.ZodObject<{
|
|
|
7
7
|
}, z.core.$strip>;
|
|
8
8
|
export type CloudflareScope = z.infer<typeof cloudflareScopeSchema>;
|
|
9
9
|
export declare const workspaceSectionSchema: z.ZodEnum<{
|
|
10
|
+
zones: "zones";
|
|
11
|
+
settings: "settings";
|
|
10
12
|
overview: "overview";
|
|
11
13
|
reports: "reports";
|
|
12
|
-
zones: "zones";
|
|
13
14
|
"rule-analyzer": "rule-analyzer";
|
|
14
15
|
accounts: "accounts";
|
|
15
16
|
checkups: "checkups";
|
|
16
|
-
settings: "settings";
|
|
17
17
|
}>;
|
|
18
18
|
export type WorkspaceSection = z.infer<typeof workspaceSectionSchema>;
|
|
19
19
|
export declare const accountResourceSchema: z.ZodEnum<{
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -23,12 +23,15 @@ export type CliConfigLegacy = CliConfig & {
|
|
|
23
23
|
export declare function parseOrigin(raw: string, option: string): string;
|
|
24
24
|
/** Default request timeout; also used when library callers omit `timeoutMs`. */
|
|
25
25
|
export declare const DEFAULT_TIMEOUT_MS = 60000;
|
|
26
|
+
/** localhost, 127.0.0.0/8 or [::1]: the hosts every origin option accepts for local development. */
|
|
27
|
+
export declare function isLoopbackHostname(hostname: string): boolean;
|
|
26
28
|
export declare function resolvePublicBaseUrl(explicit?: string | null): string;
|
|
27
29
|
export declare function resolveOneBaseUrl(explicit?: string | null): string;
|
|
28
30
|
/** Validate at transport boundaries too: library callers may construct config objects. */
|
|
29
31
|
export declare function validateOneOrigin(raw: string): string;
|
|
30
32
|
/** Production Identity: the only issuer trusted unless --auth-url / VIGILBASE_AUTH_URL names another. */
|
|
31
33
|
export declare const DEFAULT_IDENTITY_ISSUER = "https://auth.vigilbase.com";
|
|
34
|
+
/** Internal: accepted when named explicitly, but not offered to users (it isn't deployed publicly). */
|
|
32
35
|
export declare const STAGING_IDENTITY_ISSUER = "https://identity-staging.vigilbase.com";
|
|
33
36
|
export declare function resolveIdentityBaseUrl(raw: string): string;
|
|
34
37
|
/**
|
|
@@ -38,6 +41,11 @@ export declare function resolveIdentityBaseUrl(raw: string): string;
|
|
|
38
41
|
*/
|
|
39
42
|
export declare function trustedIdentityIssuer(config: Pick<CliConfig, 'oneBaseUrl' | 'identityBaseUrl'>): string;
|
|
40
43
|
export declare function resolveClientId(explicit?: string | null): string;
|
|
44
|
+
/** Why a config directory that isn't absolute was refused, naming the variable it came from. */
|
|
45
|
+
export declare function relativeConfigDirProblem(configDir: string, env?: NodeJS.ProcessEnv): {
|
|
46
|
+
reason: string;
|
|
47
|
+
remedy: string;
|
|
48
|
+
};
|
|
41
49
|
/** The config directory, which must be absolute. */
|
|
42
50
|
export declare function resolveConfigDir(explicit?: string | null, env?: NodeJS.ProcessEnv): string;
|
|
43
51
|
export declare function createConfig(options?: {
|
|
@@ -48,6 +56,14 @@ export declare function createConfig(options?: {
|
|
|
48
56
|
identityBaseUrl?: string | null;
|
|
49
57
|
configDir?: string | null;
|
|
50
58
|
timeoutMs?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Which origins to validate now; both by default. The CLI leaves out those a command never contacts, so a bad
|
|
61
|
+
* value for them cannot fail it. Every One and Identity transport validates its origin again at use.
|
|
62
|
+
*/
|
|
63
|
+
checkOrigins?: {
|
|
64
|
+
public: boolean;
|
|
65
|
+
one: boolean;
|
|
66
|
+
};
|
|
51
67
|
}): CliConfigLegacy;
|
|
52
68
|
/**
|
|
53
69
|
* Config for `vigilbase local …` and `vigilbase cloudflare-checkup`. Local
|
package/dist/lib/config.js
CHANGED
|
@@ -20,41 +20,63 @@ export function parseOrigin(raw, option) {
|
|
|
20
20
|
const timeoutSchema = z.number().int().min(1).max(2_147_483_647);
|
|
21
21
|
/** Default request timeout; also used when library callers omit `timeoutMs`. */
|
|
22
22
|
export const DEFAULT_TIMEOUT_MS = 60_000;
|
|
23
|
+
/** localhost, 127.0.0.0/8 or [::1]: the hosts every origin option accepts for local development. */
|
|
24
|
+
export function isLoopbackHostname(hostname) {
|
|
25
|
+
return hostname === 'localhost' || hostname === '[::1]' || /^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname);
|
|
26
|
+
}
|
|
27
|
+
/** An option given as an empty string is a mistake (an unset shell variable), not a request for the default. */
|
|
28
|
+
function explicitValue(raw, option) {
|
|
29
|
+
if (raw === undefined || raw === null)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (!raw.trim())
|
|
32
|
+
throw new Error(`${option} is empty. Pass an origin or leave the option out.`);
|
|
33
|
+
return raw.trim();
|
|
34
|
+
}
|
|
35
|
+
/** The first set value with the name it came from; blank variables count as unset. */
|
|
36
|
+
function firstSet(sources) {
|
|
37
|
+
for (const [name, value] of sources)
|
|
38
|
+
if (value?.trim())
|
|
39
|
+
return [name, value.trim()];
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
23
42
|
export function resolvePublicBaseUrl(explicit) {
|
|
24
|
-
const raw =
|
|
25
|
-
|
|
26
|
-
process.env.
|
|
27
|
-
process.env.
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
const [name, raw] = firstSet([
|
|
44
|
+
['--base-url', explicitValue(explicit, '--base-url')],
|
|
45
|
+
['VIGILBASE_BASE_URL', process.env.VIGILBASE_BASE_URL],
|
|
46
|
+
['VIGILBASE_API_URL', process.env.VIGILBASE_API_URL],
|
|
47
|
+
['VIGILBASE_PUBLIC_URL', process.env.VIGILBASE_PUBLIC_URL],
|
|
48
|
+
]) ?? ['--base-url', DEFAULT_PUBLIC_BASE_URL];
|
|
49
|
+
const origin = parseOrigin(raw, name);
|
|
50
|
+
const url = new URL(origin);
|
|
51
|
+
if (url.protocol !== 'https:' && !isLoopbackHostname(url.hostname)) {
|
|
52
|
+
throw new Error(`${name} requires HTTPS; HTTP is allowed only for loopback development.`);
|
|
53
|
+
}
|
|
54
|
+
return origin;
|
|
30
55
|
}
|
|
31
56
|
export function resolveOneBaseUrl(explicit) {
|
|
32
|
-
|
|
33
|
-
process.env.VIGILBASE_ONE_URL?.trim() ||
|
|
34
|
-
DEFAULT_ONE_BASE_URL;
|
|
35
|
-
return validateOneOrigin(raw);
|
|
57
|
+
return validateOneOrigin(explicitValue(explicit, '--one-url') || process.env.VIGILBASE_ONE_URL?.trim() || DEFAULT_ONE_BASE_URL);
|
|
36
58
|
}
|
|
37
59
|
/** Validate at transport boundaries too: library callers may construct config objects. */
|
|
38
60
|
export function validateOneOrigin(raw) {
|
|
39
61
|
const origin = parseOrigin(raw, '--one-url / VIGILBASE_ONE_URL');
|
|
40
62
|
const url = new URL(origin);
|
|
41
|
-
|
|
42
|
-
if (url.protocol !== 'https:' && !loopback) {
|
|
63
|
+
if (url.protocol !== 'https:' && !isLoopbackHostname(url.hostname)) {
|
|
43
64
|
throw new Error('--one-url / VIGILBASE_ONE_URL requires HTTPS; HTTP is allowed only for loopback development.');
|
|
44
65
|
}
|
|
45
66
|
return origin;
|
|
46
67
|
}
|
|
47
68
|
/** Production Identity: the only issuer trusted unless --auth-url / VIGILBASE_AUTH_URL names another. */
|
|
48
69
|
export const DEFAULT_IDENTITY_ISSUER = 'https://auth.vigilbase.com';
|
|
70
|
+
/** Internal: accepted when named explicitly, but not offered to users (it isn't deployed publicly). */
|
|
49
71
|
export const STAGING_IDENTITY_ISSUER = 'https://identity-staging.vigilbase.com';
|
|
50
72
|
const IDENTITY_OPTION = '--auth-url / VIGILBASE_AUTH_URL';
|
|
51
73
|
function isIdentityLoopback(origin) {
|
|
52
|
-
return
|
|
74
|
+
return isLoopbackHostname(new URL(origin).hostname);
|
|
53
75
|
}
|
|
54
76
|
export function resolveIdentityBaseUrl(raw) {
|
|
55
77
|
const origin = parseOrigin(raw, IDENTITY_OPTION);
|
|
56
78
|
if (origin !== DEFAULT_IDENTITY_ISSUER && origin !== STAGING_IDENTITY_ISSUER && !isIdentityLoopback(origin)) {
|
|
57
|
-
throw new Error(`${IDENTITY_OPTION} must be ${DEFAULT_IDENTITY_ISSUER}
|
|
79
|
+
throw new Error(`${IDENTITY_OPTION} must be ${DEFAULT_IDENTITY_ISSUER} or a loopback development origin.`);
|
|
58
80
|
}
|
|
59
81
|
return origin;
|
|
60
82
|
}
|
|
@@ -85,18 +107,54 @@ function configDirFromEnvironment(explicit, env) {
|
|
|
85
107
|
const custom = explicit?.trim() || env.VIGILBASE_CONFIG_DIR?.trim();
|
|
86
108
|
if (custom)
|
|
87
109
|
return custom;
|
|
110
|
+
// Set but blank: '' is not absolute, so the store rejects it rather than falling back to another directory.
|
|
111
|
+
if (env.VIGILBASE_CONFIG_DIR !== undefined)
|
|
112
|
+
return '';
|
|
88
113
|
const xdg = env.XDG_CONFIG_HOME?.trim();
|
|
89
114
|
return xdg && isAbsolute(xdg) ? join(xdg, 'vigilbase') : join(homedir(), '.config', 'vigilbase');
|
|
90
115
|
}
|
|
116
|
+
/** Why a config directory that isn't absolute was refused, naming the variable it came from. */
|
|
117
|
+
export function relativeConfigDirProblem(configDir, env = process.env) {
|
|
118
|
+
if (!configDir && env.VIGILBASE_CONFIG_DIR !== undefined) {
|
|
119
|
+
return { reason: 'VIGILBASE_CONFIG_DIR is empty', remedy: 'Set it to an absolute path or unset it.' };
|
|
120
|
+
}
|
|
121
|
+
if (env.VIGILBASE_CONFIG_DIR === undefined && !isAbsolute(homedir()) && configDir === join(homedir(), '.config', 'vigilbase')) {
|
|
122
|
+
const xdg = env.XDG_CONFIG_HOME?.trim() ? ', and a relative XDG_CONFIG_HOME is ignored' : '';
|
|
123
|
+
return { reason: `HOME (${homedir()}) is not an absolute path${xdg}`, remedy: 'Set HOME or VIGILBASE_CONFIG_DIR to an absolute path.' };
|
|
124
|
+
}
|
|
125
|
+
return { reason: `${configDir} is not an absolute path`, remedy: 'Set VIGILBASE_CONFIG_DIR to an absolute path.' };
|
|
126
|
+
}
|
|
91
127
|
/** The config directory, which must be absolute. */
|
|
92
128
|
export function resolveConfigDir(explicit, env = process.env) {
|
|
93
129
|
const configDir = configDirFromEnvironment(explicit, env);
|
|
94
|
-
if (!isAbsolute(configDir))
|
|
95
|
-
|
|
130
|
+
if (!isAbsolute(configDir)) {
|
|
131
|
+
const { reason, remedy } = relativeConfigDirProblem(configDir, env);
|
|
132
|
+
throw new Error(`${reason}. ${remedy}`);
|
|
133
|
+
}
|
|
96
134
|
return configDir;
|
|
97
135
|
}
|
|
136
|
+
/** The origin when it is valid, otherwise the value as given: left for the transport that uses it to reject. */
|
|
137
|
+
function checkedLater(resolve, raw) {
|
|
138
|
+
try {
|
|
139
|
+
return resolve();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return raw;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
98
145
|
export function createConfig(options) {
|
|
99
|
-
const
|
|
146
|
+
const checks = options?.checkOrigins ?? { public: true, one: true };
|
|
147
|
+
const explicitPublic = options?.publicBaseUrl ?? options?.baseUrl;
|
|
148
|
+
const publicBaseUrl = checks.public
|
|
149
|
+
? resolvePublicBaseUrl(explicitPublic)
|
|
150
|
+
: checkedLater(() => resolvePublicBaseUrl(explicitPublic), explicitPublic?.trim() || process.env.VIGILBASE_BASE_URL?.trim() ||
|
|
151
|
+
process.env.VIGILBASE_API_URL?.trim() || process.env.VIGILBASE_PUBLIC_URL?.trim() || DEFAULT_PUBLIC_BASE_URL);
|
|
152
|
+
const oneBaseUrl = checks.one
|
|
153
|
+
? resolveOneBaseUrl(options?.oneBaseUrl)
|
|
154
|
+
: checkedLater(() => resolveOneBaseUrl(options?.oneBaseUrl), options?.oneBaseUrl?.trim() || process.env.VIGILBASE_ONE_URL?.trim() || DEFAULT_ONE_BASE_URL);
|
|
155
|
+
const authUrl = checks.one
|
|
156
|
+
? explicitValue(options?.identityBaseUrl, '--auth-url') || process.env.VIGILBASE_AUTH_URL?.trim()
|
|
157
|
+
: options?.identityBaseUrl?.trim() || process.env.VIGILBASE_AUTH_URL?.trim();
|
|
100
158
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
101
159
|
if (!timeoutSchema.safeParse(timeoutMs).success) {
|
|
102
160
|
throw new Error('Request timeout must be an integer between 1 and 2147483647 milliseconds.');
|
|
@@ -105,8 +163,8 @@ export function createConfig(options) {
|
|
|
105
163
|
publicBaseUrl,
|
|
106
164
|
/** Alias used by the current public tools client. */
|
|
107
165
|
baseUrl: publicBaseUrl,
|
|
108
|
-
oneBaseUrl
|
|
109
|
-
...(
|
|
166
|
+
oneBaseUrl,
|
|
167
|
+
...(authUrl ? { identityBaseUrl: checks.one ? resolveIdentityBaseUrl(authUrl) : checkedLater(() => resolveIdentityBaseUrl(authUrl), authUrl) } : {}),
|
|
110
168
|
clientId: resolveClientId(options?.clientId),
|
|
111
169
|
configDir: configDirFromEnvironment(options?.configDir, process.env),
|
|
112
170
|
userAgent: `@vigilbase/cli/${CLI_VERSION} (+https://vigilbase.com; node/${process.version})`,
|
package/dist/lib/format.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export type Writer = {
|
|
|
5
5
|
};
|
|
6
6
|
/** A scan that ended without a result: com's copy (apps/com/lib/tools/execute-internal-scan.ts). */
|
|
7
7
|
export declare const SCAN_FAILED_MESSAGE = "The scan couldn't be completed. Try again later.";
|
|
8
|
-
/** Untrusted API data must never become terminal control sequences or
|
|
8
|
+
/** Untrusted API data must never become terminal control sequences, bidi overrides or invisible text. */
|
|
9
9
|
export declare function sanitizeTerminal(value: string): string;
|
|
10
10
|
/** One sanitised line, cut to `maxLength` characters with an ellipsis. */
|
|
11
11
|
export declare function singleLine(value: string, maxLength?: number): string;
|
package/dist/lib/format.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { LOCAL_ERROR_CODES } from '../local/cloudflare-checkup/errors.js';
|
|
2
3
|
/** A scan that ended without a result: com's copy (apps/com/lib/tools/execute-internal-scan.ts). */
|
|
3
4
|
export const SCAN_FAILED_MESSAGE = "The scan couldn't be completed. Try again later.";
|
|
4
5
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
5
|
-
/**
|
|
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. */
|
|
6
14
|
export function sanitizeTerminal(value) {
|
|
7
|
-
return stripVTControlCharacters(value).replace(
|
|
15
|
+
return stripVTControlCharacters(value).replace(TERMINAL_UNSAFE, '');
|
|
8
16
|
}
|
|
9
17
|
/** One sanitised line, cut to `maxLength` characters with an ellipsis. */
|
|
10
18
|
export function singleLine(value, maxLength = 300) {
|
|
@@ -25,7 +33,7 @@ export function createWriter(stdout = process.stdout, stderr = process.stderr) {
|
|
|
25
33
|
}
|
|
26
34
|
export function printJson(writer, value) {
|
|
27
35
|
// Keep JSON lossless while preventing literal terminal/bidi controls in rendered output.
|
|
28
|
-
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('')));
|
|
29
37
|
}
|
|
30
38
|
function asRecord(value) {
|
|
31
39
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -156,6 +164,9 @@ function collectFindings(result) {
|
|
|
156
164
|
if (!header)
|
|
157
165
|
continue;
|
|
158
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;
|
|
159
170
|
if (status !== 'missing' && status !== 'misconfigured' && header.present !== false) {
|
|
160
171
|
continue;
|
|
161
172
|
}
|
|
@@ -191,30 +202,49 @@ function collectFindings(result) {
|
|
|
191
202
|
return findings;
|
|
192
203
|
}
|
|
193
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
|
+
}
|
|
194
220
|
/** Compact human lines for a scan result (no UI/skill links, no summary dump). */
|
|
195
221
|
function summarizeResultCompact(result) {
|
|
196
222
|
if (!result)
|
|
197
223
|
return [];
|
|
224
|
+
// Server text never starts a line of its own: each message line is indented like the rest.
|
|
198
225
|
const lines = [];
|
|
199
226
|
const message = typeof result.message === 'string' ? result.message : null;
|
|
200
227
|
if (message)
|
|
201
|
-
lines.push(message);
|
|
228
|
+
lines.push(...sanitizeTerminal(message).split('\n').map((line) => line.trim()).filter(Boolean));
|
|
202
229
|
const counts = summarizeCounts(result);
|
|
203
230
|
if (counts)
|
|
204
231
|
lines.push(counts);
|
|
205
232
|
const findings = collectFindings(result);
|
|
206
233
|
for (const finding of findings.slice(0, MAX_FINDING_LINES)) {
|
|
207
|
-
const sev = finding.severity ? ` ${finding.severity}` : '';
|
|
234
|
+
const sev = finding.severity ? ` ${singleLine(finding.severity, 20)}` : '';
|
|
208
235
|
if (finding.recommendation) {
|
|
209
|
-
lines.push(`✗${sev} ${finding.title} — ${finding.recommendation}`);
|
|
236
|
+
lines.push(`✗${sev} ${singleLine(finding.title)} — ${singleLine(finding.recommendation)}`);
|
|
210
237
|
}
|
|
211
238
|
else {
|
|
212
|
-
lines.push(`✗${sev} ${finding.title}`);
|
|
239
|
+
lines.push(`✗${sev} ${singleLine(finding.title)}`);
|
|
213
240
|
}
|
|
214
241
|
}
|
|
215
242
|
if (findings.length > MAX_FINDING_LINES) {
|
|
216
243
|
lines.push(`… +${findings.length - MAX_FINDING_LINES} more (use --json)`);
|
|
217
244
|
}
|
|
245
|
+
const report = reportUrl(result.shareableUrl);
|
|
246
|
+
if (report)
|
|
247
|
+
lines.push(`Report: ${report}`);
|
|
218
248
|
return lines;
|
|
219
249
|
}
|
|
220
250
|
export function formatToolsList(catalog, mode, writer, options) {
|
|
@@ -222,11 +252,12 @@ export function formatToolsList(catalog, mode, writer, options) {
|
|
|
222
252
|
printJson(writer, catalog);
|
|
223
253
|
return;
|
|
224
254
|
}
|
|
225
|
-
|
|
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(' ');
|
|
226
257
|
writer.stdout(`public ${publicNames || '(none)'}`);
|
|
227
258
|
const authRequired = options?.authRequiredTools ?? options?.sensitiveTools ?? [];
|
|
228
259
|
if (authRequired.length) {
|
|
229
|
-
writer.stdout(`auth-req ${authRequired.join(' ')}`);
|
|
260
|
+
writer.stdout(`auth-req ${authRequired.map((name) => singleLine(name, 100)).join(' ')}`);
|
|
230
261
|
}
|
|
231
262
|
if (typeof options?.isAuthenticated === 'boolean') {
|
|
232
263
|
writer.stdout(options.isAuthenticated ? 'auth signed in' : 'auth signed out');
|
|
@@ -234,23 +265,24 @@ export function formatToolsList(catalog, mode, writer, options) {
|
|
|
234
265
|
}
|
|
235
266
|
export function formatToolDescription(tool, mode, writer, skillMarkdown) {
|
|
236
267
|
if (mode === 'json') {
|
|
237
|
-
printJson(writer, { ...tool, skillMarkdown: skillMarkdown ?? null });
|
|
268
|
+
printJson(writer, { success: true, ...tool, skillMarkdown: skillMarkdown ?? null });
|
|
238
269
|
return;
|
|
239
270
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
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>`);
|
|
246
277
|
if (skillMarkdown) {
|
|
247
278
|
writer.stdout();
|
|
248
279
|
writer.stdout(skillMarkdown.trimEnd());
|
|
249
280
|
}
|
|
250
281
|
}
|
|
251
282
|
/**
|
|
252
|
-
* Why a scan failed, or null when it has a result. A scan
|
|
253
|
-
*
|
|
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.
|
|
254
286
|
*/
|
|
255
287
|
function scanFailure(result) {
|
|
256
288
|
if (result.success && result.result?.status !== 'error')
|
|
@@ -273,24 +305,37 @@ export function formatExecuteResult(result, mode, writer) {
|
|
|
273
305
|
printJson(writer, failure === null ? result : { ...result, success: false, error: failure, grade: null, gradeScore: null });
|
|
274
306
|
return;
|
|
275
307
|
}
|
|
276
|
-
const tool = result.toolName ?? 'tool';
|
|
277
|
-
const target = result.target ?? '';
|
|
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) : '';
|
|
278
311
|
const grade = failure === null && result.grade != null
|
|
279
|
-
? `${
|
|
312
|
+
? `${gradeLetter}${result.gradeScore != null ? ` ${singleLine(String(result.gradeScore), 10)}` : ''}`
|
|
280
313
|
: 'n/a';
|
|
281
314
|
if (failure !== null) {
|
|
282
315
|
writer.stderr(`✗ ${tool} · ${target} · ${grade}`);
|
|
283
316
|
writer.stderr(` ${failure}`);
|
|
284
317
|
return;
|
|
285
318
|
}
|
|
286
|
-
|
|
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}`);
|
|
287
322
|
for (const line of summarizeResultCompact(result.result)) {
|
|
288
323
|
writer.stdout(` ${line}`);
|
|
289
324
|
}
|
|
290
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
|
+
}
|
|
291
332
|
export function formatError(error, mode, writer) {
|
|
292
333
|
let message = error instanceof Error ? error.message : String(error);
|
|
293
334
|
let reason = '';
|
|
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.';
|
|
294
339
|
// Server bodies can contain credentials, customer data, or arbitrary error text, so an error that
|
|
295
340
|
// carries one shows only text the CLI wrote, or the reason the public client accepted.
|
|
296
341
|
if (error && typeof error === 'object' && 'body' in error && error.body != null && 'status' in error && typeof error.status === 'number') {
|
|
@@ -308,6 +353,8 @@ export function formatError(error, mode, writer) {
|
|
|
308
353
|
success: false,
|
|
309
354
|
error: message,
|
|
310
355
|
};
|
|
356
|
+
if (zodIssues)
|
|
357
|
+
payload.code = 'INVALID_ARGUMENT';
|
|
311
358
|
if (error && typeof error === 'object') {
|
|
312
359
|
if ('status' in error && typeof error.status === 'number' &&
|
|
313
360
|
Number.isInteger(error.status) && error.status >= 0 && error.status <= 599) {
|
|
@@ -339,9 +386,13 @@ export function formatError(error, mode, writer) {
|
|
|
339
386
|
printJson(writer, payload);
|
|
340
387
|
return;
|
|
341
388
|
}
|
|
342
|
-
|
|
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})` : ''}`);
|
|
343
392
|
if (typeof payload.retryAfterSec === 'number') {
|
|
344
|
-
|
|
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`);
|
|
345
396
|
}
|
|
346
397
|
if (typeof payload.hint === 'string') {
|
|
347
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
|
}>;
|