@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
212 lines (211 loc) • 10.5 kB
JavaScript
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).`);
}
}
}