@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
396 lines (395 loc) • 20.6 kB
JavaScript
import { NetworkError, readBoundedBody } from '../../lib/network.js';
import { CLI_VERSION_HEADER, LOCAL_AVAILABILITY_PATH, LOCAL_REQUEST_PATH, LOCAL_SESSION_PATH, LOCAL_SNAPSHOT_PATH, LOCAL_VERIFY_PATH, LocalAvailabilityResponseSchema, LocalErrorResponseSchema, LocalRequestBodySchema, LocalRequestResponseSchema, LocalSessionResponseSchema, LocalSnapshotResponseSchema, LocalVerifyBodySchema, LocalVerifyResponseSchema, PRODUCTION_ORIGIN, SESSION_CODE_REGEX, SNAPSHOT_SHA256_HEADER, compareSemver, } from './contract.js';
import { LocalCheckupError, browserCheckupHint } from './errors.js';
import { formatDuration } from './output.js';
export const GET_TIMEOUT_MS = 30_000;
/** The availability check is skipped (the run goes on) when vigilbase.com does not answer it within this. */
export const AVAILABILITY_TIMEOUT_MS = 10_000;
export const POST_TIMEOUT_MS = 120_000;
export const MAX_SUBMIT_RESPONSE_BYTES = 1024 * 1024;
export const POST_RETRY_DELAYS_MS = [2_000, 5_000];
/** A verify that failed on the network or with a 503 is sent once more, after this pause. */
export const VERIFY_RETRY_DELAYS_MS = [2_000];
function isLoopbackHost(hostname) {
return hostname === 'localhost' || hostname === '[::1]' || /^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname);
}
/** True for http(s) loopback origins (local development), the only ones besides production that `--base-url` accepts. */
export function isLoopbackOrigin(origin) {
let url;
try {
url = new URL(origin);
}
catch {
return false;
}
return (url.protocol === 'http:' || url.protocol === 'https:') && isLoopbackHost(url.hostname);
}
/**
* The submit origin. Only `https://vigilbase.com` and loopback origins are
* accepted; there is no `*.vigilbase.com` wildcard (other subdomains are
* different services). Environment variables are never consulted.
*/
export function resolveSubmitOrigin(explicit) {
if (explicit === undefined)
return PRODUCTION_ORIGIN;
let url;
try {
url = new URL(explicit.trim());
}
catch {
throw new LocalCheckupError('SUBMIT_ORIGIN_UNTRUSTED', '--base-url is not a valid origin.');
}
const originOnly = !url.username && !url.password && /^\/*$/.test(url.pathname) && !url.search && !url.hash;
if (originOnly && url.origin === PRODUCTION_ORIGIN)
return PRODUCTION_ORIGIN;
if (originOnly && isLoopbackOrigin(url.origin))
return url.origin;
// Do not echo the value: it may contain credentials.
throw new LocalCheckupError('SUBMIT_ORIGIN_UNTRUSTED', 'Local checkups submit only to https://vigilbase.com or a loopback origin.');
}
const REASON_CODES = {
session_invalid: 'SESSION_INVALID',
session_expired: 'SESSION_EXPIRED',
session_used: 'SESSION_USED',
session_busy: 'SESSION_BUSY',
session_not_ready: 'SESSION_INVALID',
run_closed: 'SESSION_CLOSED',
browser_origin: 'SUBMIT_FAILED',
malformed_json: 'SUBMIT_FAILED',
digest_mismatch: 'SUBMIT_FAILED',
unsupported_media_type: 'SUBMIT_FAILED',
length_required: 'SUBMIT_FAILED',
snapshot_too_large: 'SNAPSHOT_TOO_LARGE',
snapshot_invalid: 'SNAPSHOT_INVALID',
snapshot_incomplete: 'SNAPSHOT_INVALID',
account_mismatch: 'ACCOUNT_MISMATCH',
snapshot_stale: 'SNAPSHOT_STALE',
collector_unsupported: 'CLI_UPGRADE_REQUIRED',
secret_like_value: 'SNAPSHOT_SECRET_LIKE_VALUE',
evaluation_failed: 'SUBMIT_FAILED',
rate_limited: 'VIGILBASE_RATE_LIMITED',
local_mode_disabled: 'LOCAL_MODE_DISABLED',
storage_unavailable: 'SUBMIT_FAILED',
request_invalid: 'SUBMIT_FAILED',
email_invalid: 'EMAIL_INVALID',
code_invalid: 'EMAIL_CODE_INVALID',
verification_invalid: 'VERIFICATION_INVALID',
verification_expired: 'VERIFICATION_EXPIRED',
};
const REASON_MESSAGES = {
session_invalid: 'The checkup session is not valid (revoked or for another checkup).',
session_expired: 'The checkup session has expired.',
session_used: 'This checkup already has results from a different snapshot.',
session_busy: 'A different snapshot is already being processed for this checkup.',
session_not_ready: 'This checkup is not ready for an upload yet; its email is not verified.',
run_closed: 'This checkup was cancelled or has expired.',
browser_origin: 'vigilbase.com refused the request as a browser request.',
malformed_json: 'vigilbase.com could not read the uploaded file as JSON.',
digest_mismatch: 'The upload was corrupted in transit (SHA-256 mismatch).',
unsupported_media_type: 'vigilbase.com rejected the upload content type.',
length_required: 'vigilbase.com required a Content-Length for the upload.',
snapshot_too_large: 'The snapshot is larger than vigilbase.com accepts.',
snapshot_invalid: 'vigilbase.com rejected the snapshot as invalid.',
snapshot_incomplete: 'vigilbase.com rejected the snapshot as incomplete (missing or extra reads).',
account_mismatch: 'The snapshot is for a different Cloudflare account than this checkup.',
snapshot_stale: 'The snapshot is older than 24 hours (or dated in the future).',
collector_unsupported: 'vigilbase.com no longer accepts snapshots from this CLI or cf version.',
secret_like_value: 'vigilbase.com found a value that looks like a credential in the snapshot.',
evaluation_failed: 'vigilbase.com could not evaluate the snapshot; the checkup is unchanged.',
rate_limited: 'vigilbase.com rate-limited the upload.',
local_mode_disabled: 'Local checkups aren\'t available right now.',
storage_unavailable: 'vigilbase.com storage is temporarily unavailable.',
request_invalid: 'vigilbase.com could not read the verification request.',
email_invalid: 'vigilbase.com accepts work email addresses only (not personal or disposable ones).',
code_invalid: 'That code is not correct.',
verification_invalid: 'The email verification is not valid.',
verification_expired: 'The email verification has expired (the code timed out, was tried too often, or the checkup closed).',
};
const RATE_LIMITED_MESSAGES = {
verification: 'vigilbase.com rate-limited verification codes for now.',
session: REASON_MESSAGES.rate_limited,
};
function retryAfter(response, body) {
if (body?.retryAfterSec !== undefined)
return body.retryAfterSec;
const header = response.headers.get('retry-after');
if (header && /^\d{1,6}$/.test(header.trim()))
return Number(header.trim());
return undefined;
}
function isChallenge(response) {
if ((response.headers.get('cf-mitigated') ?? '').toLowerCase().includes('challenge'))
return true;
const type = (response.headers.get('content-type') ?? '').toLowerCase();
return (response.status === 403 || response.status === 503) && type.includes('text/html');
}
/** Map a non-2xx response from `origin` to a CLI error; never echoes server text. */
function responseError(response, text, endpoint, origin) {
if (isChallenge(response)) {
return new LocalCheckupError('VIGILBASE_CHALLENGED', 'vigilbase.com answered with a bot or WAF challenge instead of the API.', { status: response.status });
}
let body = null;
try {
const parsed = LocalErrorResponseSchema.safeParse(JSON.parse(text));
if (parsed.success)
body = parsed.data;
}
catch {
body = null;
}
const status = response.status;
const after = retryAfter(response, body);
const timing = after !== undefined ? { retryAfterSec: after } : {};
const reason = body?.reason;
if (status === 429 || reason === 'rate_limited') {
const wait = after !== undefined ? ` Try again in ${formatDuration(after)}.` : '';
return new LocalCheckupError('VIGILBASE_RATE_LIMITED', `${RATE_LIMITED_MESSAGES[endpoint]}${wait}`, { status, ...timing });
}
if (reason) {
// Local mode switched off: the browser checkup on the same origin (vigilbase.com, or a loopback dev server).
const hint = reason === 'local_mode_disabled' ? { hint: browserCheckupHint(origin) } : {};
return new LocalCheckupError(REASON_CODES[reason], REASON_MESSAGES[reason], { status, ...timing, ...hint });
}
const fallback = status === 401 ? (endpoint === 'verification' ? 'VERIFICATION_INVALID' : 'SESSION_INVALID')
: status === 410 ? (endpoint === 'verification' ? 'VERIFICATION_EXPIRED' : 'SESSION_CLOSED')
: status === 413 ? 'SNAPSHOT_TOO_LARGE' : 'SUBMIT_FAILED';
return new LocalCheckupError(fallback, `vigilbase.com rejected the request (HTTP ${status}).`, { status });
}
/** No answer (a network error or a timeout), or vigilbase.com storage briefly unavailable (503); never a challenge or the kill switch. */
function isTransientFailure(error) {
return error instanceof LocalCheckupError && error.code === 'SUBMIT_FAILED' && (error.status === 0 || error.status === 503);
}
/** A successful JSON body validated against `schema`, or SUBMIT_FAILED naming what was expected. */
function parseSuccess(schema, response, text, what) {
let parsed;
try {
parsed = schema.safeParse(JSON.parse(text));
}
catch {
parsed = null;
}
if (!parsed?.success)
throw new LocalCheckupError('SUBMIT_FAILED', `vigilbase.com returned an unexpected ${what} response.`, { status: response.status });
return parsed.data;
}
/**
* The results page from an upload response, as printable ASCII (`URL.href`
* percent-encodes everything else), or undefined when it is not a plain
* http(s) URL. The caller decides whether to open it (same origin only).
*/
export function trustedResultsUrl(raw) {
if (raw === undefined)
return undefined;
let url;
try {
url = new URL(raw);
}
catch {
return undefined;
}
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password)
return undefined;
return /^[\x21-\x7e]{1,2048}$/.test(url.href) ? url.href : undefined;
}
/** Shared transport: CLI headers, no redirects, bounded responses, CLI-authored network errors. */
class LocalHttpClient {
options;
constructor(options) {
this.options = options;
}
sleep(ms) {
return this.options.sleep ? this.options.sleep(ms) : new Promise((resolve) => setTimeout(resolve, ms));
}
headers(extra = {}) {
return {
Accept: 'application/json',
'User-Agent': this.options.userAgent,
[CLI_VERSION_HEADER]: this.options.cliVersion,
...extra,
};
}
async request(path, init, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const url = `${this.options.origin}${path}`;
const send = this.options.fetch ?? fetch;
try {
// Never follow redirects: credentials and bodies must reach only the checked origin.
// 'manual' surfaces the 3xx (Node returns its real status; browsers an
// opaque redirect) so it fails fast instead of looking like a network error.
const response = await send(url, { ...init, signal: controller.signal, redirect: 'manual' });
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
await response.body?.cancel().catch(() => undefined);
throw new LocalCheckupError('SUBMIT_FAILED', `${this.options.origin} redirected the request (HTTP ${response.status || '3xx'}); the redirect was not followed and nothing was processed.`, { status: response.status || 302 });
}
return { response, text: await readBoundedBody(response, controller.signal, MAX_SUBMIT_RESPONSE_BYTES) };
}
catch (error) {
if (error instanceof LocalCheckupError)
throw error;
if (error instanceof NetworkError && error.code === 'RESPONSE_TOO_LARGE') {
throw new LocalCheckupError('SUBMIT_FAILED', 'vigilbase.com returned an unexpectedly large response.');
}
throw new LocalCheckupError('SUBMIT_FAILED', controller.signal.aborted
? `The request to ${this.options.origin} timed out after ${timeoutMs}ms.`
: `Could not connect to ${this.options.origin}. Check your connection, VPN or proxy.`, { status: 0 });
}
finally {
clearTimeout(timer);
}
}
}
/**
* The requests before the session code, without a bearer: the availability
* check and the email verification. A code request is never retried: a
* repeated request would send a second email. A verify that failed on the
* network (or timed out) or with a 503 is sent once more, after 2 s, with the
* same token and code: One counts only wrong codes, and the right code
* verified again mints the next session code, as a renewal does. Only when
* the lost answer was to a wrong code does the retry cost a second try.
*/
export class VerificationClient extends LocalHttpClient {
async postJson(path, body) {
const result = await this.request(path, {
method: 'POST',
headers: this.headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
}, this.options.timeoutMs ?? GET_TIMEOUT_MS);
if (!result.response.ok || isChallenge(result.response))
throw responseError(result.response, result.text, 'verification', this.options.origin);
return result;
}
/**
* `GET local-session/availability`, before the email is asked for: what
* vigilbase.com accepts, or null when it gave no usable answer (a 404 from a
* server without the route, a network error, a timeout, a challenge or any
* other response), and the run goes on as before. Throws LOCAL_MODE_DISABLED
* when it answers that local checkups are switched off. Never retried.
*/
async availability() {
let result;
try {
result = await this.request(LOCAL_AVAILABILITY_PATH, { method: 'GET', headers: this.headers() }, this.options.timeoutMs ?? AVAILABILITY_TIMEOUT_MS);
}
catch {
return null;
}
const { response, text } = result;
if (response.ok && !isChallenge(response)) {
try {
const parsed = LocalAvailabilityResponseSchema.safeParse(JSON.parse(text));
return parsed.success ? parsed.data : null;
}
catch {
return null;
}
}
const error = responseError(response, text, 'verification', this.options.origin);
if (response.status === 503 && error.code === 'LOCAL_MODE_DISABLED')
throw error;
return null;
}
/** `POST local-session/request`: vigilbase.com emails a 6-digit code and returns the token for it. */
async requestCode(body) {
// The address is never echoed, even when it does not fit the contract.
const checked = LocalRequestBodySchema.safeParse(body);
if (!checked.success)
throw new LocalCheckupError('EMAIL_INVALID', 'That is not an email address vigilbase.com can send a code to.');
const { response, text } = await this.postJson(LOCAL_REQUEST_PATH, checked.data);
return parseSuccess(LocalRequestResponseSchema, response, text, 'verification').verification;
}
/** `POST local-session/verify`: trades the token and the emailed code for a session code (the next one on every call). */
async verifyCode(body) {
const checked = LocalVerifyBodySchema.safeParse(body);
if (!checked.success)
throw new LocalCheckupError('EMAIL_CODE_INVALID', 'The code must be the 6 digits from the email.');
for (let attempt = 0;; attempt++) {
try {
const { response, text } = await this.postJson(LOCAL_VERIFY_PATH, checked.data);
return parseSuccess(LocalVerifyResponseSchema, response, text, 'verification').session;
}
catch (error) {
if (attempt >= VERIFY_RETRY_DELAYS_MS.length || !isTransientFailure(error))
throw error;
await this.sleep(VERIFY_RETRY_DELAYS_MS[attempt]);
}
}
}
}
export class SubmitClient extends LocalHttpClient {
code;
constructor(options) {
const { code, ...rest } = options;
super(rest);
if (!SESSION_CODE_REGEX.test(code))
throw new LocalCheckupError('SESSION_INVALID', 'vigilbase.com returned a malformed checkup session.');
this.code = code;
}
bearer(extra = {}) {
return this.headers({ Authorization: `Bearer ${this.code}`, ...extra });
}
/** `GET local-session`: the bound account (or null), masked requester, expiry and accepted limits. */
async getSession() {
const { response, text } = await this.request(LOCAL_SESSION_PATH, { method: 'GET', headers: this.bearer() }, this.options.timeoutMs ?? GET_TIMEOUT_MS);
if (!response.ok || isChallenge(response))
throw responseError(response, text, 'session', this.options.origin);
return parseSuccess(LocalSessionResponseSchema, response, text, 'session').session;
}
/**
* `POST local-session/snapshot` with the exact file bytes. 503 and network
* errors are retried twice (after 2 s, then 5 s) with the same bytes, which
* the server treats idempotently. Challenges are never retried.
*/
async postSnapshot(bytes, sha256) {
for (let attempt = 0;; attempt++) {
let result;
try {
result = await this.request(LOCAL_SNAPSHOT_PATH, {
method: 'POST',
headers: this.bearer({ 'Content-Type': 'application/json', [SNAPSHOT_SHA256_HEADER]: sha256 }),
body: new Uint8Array(bytes),
}, this.options.timeoutMs ?? POST_TIMEOUT_MS);
}
catch (error) {
if (error instanceof LocalCheckupError && error.status === 0 && attempt < POST_RETRY_DELAYS_MS.length) {
await this.sleep(POST_RETRY_DELAYS_MS[attempt]);
continue;
}
throw error;
}
const { response, text } = result;
if (response.ok && !isChallenge(response)) {
const parsed = parseSuccess(LocalSnapshotResponseSchema, response, text, 'upload');
if (parsed.snapshotSha256 !== sha256) {
throw new LocalCheckupError('SUBMIT_FAILED', 'vigilbase.com acknowledged a different snapshot digest than the file uploaded.');
}
const report = { ...parsed.report };
// The report carries no run ID or share link; the results page arrives only as `resultsUrl`.
delete report.toolRunId;
delete report.shareableUrl;
delete report.shareUrl;
const resultsUrl = trustedResultsUrl(parsed.resultsUrl);
return { replayed: parsed.replayed, snapshotSha256: parsed.snapshotSha256, provenance: parsed.provenance, report, ...(resultsUrl ? { resultsUrl } : {}) };
}
const error = responseError(response, text, 'session', this.options.origin);
if (response.status === 503 && error.code !== 'VIGILBASE_CHALLENGED' && error.code !== 'LOCAL_MODE_DISABLED' && attempt < POST_RETRY_DELAYS_MS.length) {
await this.sleep(POST_RETRY_DELAYS_MS[attempt]);
continue;
}
throw error;
}
}
}
/** Refuse a server (its session, or its availability answer) that requires a newer CLI, or does not accept this schema version. */
export function assertServerAccepts(server, cliVersion, schemaVersion) {
if (!server.accepts.schemaVersions.includes(schemaVersion) || compareSemver(cliVersion, server.minCliVersion) < 0) {
throw new LocalCheckupError('CLI_UPGRADE_REQUIRED', `vigilbase.com requires @vigilbase/cli ${server.minCliVersion.slice(0, 40)} or newer for this snapshot.`);
}
}
/**
* The masked address as the server masks it (first character, `***`, the
* domain); any other text from the server is replaced by `fallback`.
*/
export function displayRequester(requester, fallback = 'your email address') {
return /^[^\s@\p{Cc}\p{Cf}]{1,4}\*{3}@[A-Za-z0-9.-]{1,253}$/u.test(requester) ? requester : fallback;
}