UNPKG

@vigilbase/cli

Version:

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

212 lines (211 loc) • 10.5 kB
import { LocalCheckupError } from './errors.js'; import { formatClock } from './output.js'; import { hasInteractiveInput, promptAnswer } from './prompts.js'; import { displayRequester } from './submit-client.js'; export const EMAIL_PROMPT = 'Work email for your results: '; export const CODE_PROMPT = 'Code from the email: '; /** Addresses asked for before giving up (a malformed one, or one vigilbase.com does not accept). */ export const MAX_EMAIL_ATTEMPTS = 3; /** Codes asked for before giving up; One locks a checkup after 5 wrong codes. */ export const MAX_CODE_ATTEMPTS = 5; /** * On a terminal, how often the code is asked for again after vigilbase.com did * not answer the verify (twice: the client already sent it once more). These * are not tries: only a wrong code uses one. */ export const MAX_UNANSWERED_VERIFIES = 2; const MAX_EMAIL_LENGTH = 254; const EMAIL_SHAPE = /^[^\s@\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]+@[^\s@\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]+$/u; /** One `@` with text on both sides, no spaces or control characters, at most 254 characters. */ export function isEmailShaped(value) { return value.length <= MAX_EMAIL_LENGTH && EMAIL_SHAPE.test(value); } /** `--email <address>`: checked before anything runs; the value is never echoed. */ export function parseEmailFlag(value) { const email = value.trim(); if (!isEmailShaped(email)) throw new LocalCheckupError('EMAIL_INVALID', '--email is not an email address.'); return email; } /** The six digits of a typed code; `123 456` and `123-456` are accepted too. Null for anything else. */ export function normalizeEmailCode(input) { const digits = input.replace(/[\s-]/g, ''); return /^\d{6}$/.test(digits) ? digits : null; } /** The email language from LC_ALL, LC_MESSAGES or LANG (the first one set): `pt*` → pt-PT, `ar*` → ar, else en. */ export function emailLocale(env) { const value = [env.LC_ALL, env.LC_MESSAGES, env.LANG].find((candidate) => candidate !== undefined && candidate.trim() !== '') ?? ''; const language = value.trim().toLowerCase(); return language.startsWith('pt') ? 'pt-PT' : language.startsWith('ar') ? 'ar' : 'en'; } /** * A verified email and the session code it earned. Private fields keep the * token, the emailed code and the session code out of `JSON.stringify` and * `util.inspect`. */ export class VerifiedEmail { #client; #token; #emailCode; #sessionCode; #expiresAt; #renewed = false; /** The masked address from vigilbase.com, safe to print. */ requester; constructor(client, token, emailCode, session) { this.#client = client; this.#token = token; this.#emailCode = emailCode; this.#sessionCode = session.code; this.#expiresAt = session.expiresAt; this.requester = displayRequester(session.requester); } /** The current session code: in memory only, sent only as the upload's bearer. */ get sessionCode() { return this.#sessionCode; } get expiresAt() { return this.#expiresAt; } /** True until `renew` has been used. */ get renewable() { return !this.#renewed; } /** * Once per run: verify again with the same token and code, and vigilbase.com * mints the next session code for the same checkup (the previous one stops * working). It fails once the emailed code expired or the checkup closed. */ async renew() { if (this.#renewed) throw new LocalCheckupError('SESSION_EXPIRED', 'The checkup session expired again after it was renewed.'); this.#renewed = true; const session = await this.#client.verifyCode({ verification: this.#token, code: this.#emailCode }); this.#sessionCode = session.code; this.#expiresAt = session.expiresAt; } toJSON() { return { requester: this.requester }; } } /** The plain prompts and status lines. */ function plainView(options) { return { askEmail: () => promptAnswer(options.io, EMAIL_PROMPT), askCode: () => promptAnswer(options.io, CODE_PROMPT), codeSent: (sentTo, expiresAt) => options.say(`We emailed a 6-digit code to ${sentTo} (valid until ${formatClock(expiresAt)}). Check spam if it doesn't arrive.`), notice: (line) => options.notice(line), requesting: (work) => work(), verifying: (work) => work(), }; } const PIPE_HINT = 'Run the command in a terminal, or pass --email <address> and pipe the code from the email to it once it asks (`Code from the email:` on stderr).'; /** vigilbase.com starts one checkup per address every 8 minutes (One's creation budget). No final period. */ export const SAME_ADDRESS_WAIT = 'The same address can get a new code 8 minutes after the last one'; /** The input ended (Ctrl-D on a terminal, or the end of piped stdin) before an answer. */ function noAnswer(options, what) { const hint = !hasInteractiveInput(options.io.stdin) ? PIPE_HINT : what === 'code' ? `Run the same command again. ${SAME_ADDRESS_WAIT}.` : 'Run the same command again and enter your work email.'; return new LocalCheckupError('CONFIRMATION_REQUIRED', `No ${what} was entered, and the input ended.`, { hint }); } /** * Ask for the address until vigilbase.com accepts one and emails a code (up to * 3 addresses). A rate limit stops the run, unless the view offers another * address and the user takes it. */ async function requestCode(options, view) { for (let attempt = 1;; attempt++) { let email = options.email; if (email === undefined) { const answer = (await view.askEmail())?.trim() ?? null; if (answer === null) throw noAnswer(options, 'email address'); if (!isEmailShaped(answer)) { if (attempt >= MAX_EMAIL_ATTEMPTS) throw new LocalCheckupError('EMAIL_INVALID', 'That is not an email address.'); view.notice('That is not an email address. Enter your work email.'); continue; } email = answer; } const address = email; try { return await view.requesting(() => options.client.requestCode({ email: address, locale: options.locale })); } catch (error) { if (!(error instanceof LocalCheckupError)) throw error; if (error.code === 'VIGILBASE_RATE_LIMITED') { if (options.email === undefined && attempt < MAX_EMAIL_ATTEMPTS && view.rateLimited && await view.rateLimited(error)) continue; throw error; } if (error.code !== 'EMAIL_INVALID') throw error; if (options.email !== undefined) { throw new LocalCheckupError('EMAIL_INVALID', 'vigilbase.com does not accept the --email address: use your work email (not a personal or disposable one).', { status: error.status }); } if (attempt >= MAX_EMAIL_ATTEMPTS) throw error; view.notice('vigilbase.com accepts work email addresses only (not personal or disposable ones). Enter another address.'); } } } /** No answer to the verify (a network error or a timeout), or a 503: the code may be sent again. */ function isUnanswered(error) { return error.code === 'SUBMIT_FAILED' && (error.status === 0 || error.status === 503); } /** * Request a code, then ask for it until vigilbase.com accepts it (up to 5 * tries; One locks the checkup after 5 wrong codes). A wrong address is asked * for again (up to 3), except with `--email`. An expired verification and a * rate limit stop with their own codes (VERIFICATION_EXPIRED, and * VIGILBASE_RATE_LIMITED with the retry time, exit 2), unless the view offers * another address for a rate limit. A verify vigilbase.com did not answer is * sent once more by the client; if that fails too, a terminal asks for the * code again (twice at most, without using a try), since a new code for the * same address would be refused for 8 minutes. */ export async function verifyEmail(options) { const view = options.view ?? plainView(options); const pending = await requestCode(options, view); view.codeSent(displayRequester(pending.requester, 'the address you entered'), pending.expiresAt); let unanswered = 0; for (let attempt = 1;; attempt++) { const left = MAX_CODE_ATTEMPTS - attempt; const answer = await view.askCode(); if (answer === null) throw noAnswer(options, 'code'); const code = normalizeEmailCode(answer); if (code === null) { if (left <= 0) throw new LocalCheckupError('EMAIL_CODE_INVALID', 'No valid 6-digit code was entered.'); view.notice(`Enter the 6 digits from the email (${left} ${left === 1 ? 'try' : 'tries'} left).`); continue; } try { // The client already sent a verify that got no answer, or a 503, once more. const session = await view.verifying(() => options.client.verifyCode({ verification: pending.token, code })); return new VerifiedEmail(options.client, pending.token, code, session); } catch (error) { if (error instanceof LocalCheckupError && isUnanswered(error) && unanswered < MAX_UNANSWERED_VERIFIES && hasInteractiveInput(options.io.stdin)) { unanswered++; // Not one of the 5 tries: the same code is still valid, and asking again costs nothing. attempt--; view.notice('vigilbase.com did not answer. Enter the same code again to retry.'); continue; } if (error instanceof LocalCheckupError && error.code === 'SUBMIT_FAILED') { // A new code now would be refused for this address: say how long to wait. throw new LocalCheckupError('SUBMIT_FAILED', error.message, { hint: `Run the same command again in a few minutes. ${SAME_ADDRESS_WAIT}.`, ...(error.status !== undefined ? { status: error.status } : {}), }); } if (!(error instanceof LocalCheckupError) || error.code !== 'EMAIL_CODE_INVALID' || left <= 0) throw error; view.notice(`That code is not correct (${left} ${left === 1 ? 'try' : 'tries'} left).`); } } }