UNPKG

@vigilbase/cli

Version:

Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.

396 lines (395 loc) • 20.6 kB
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; }