UNPKG

@vigilbase/cli

Version:

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

313 lines (312 loc) • 13.8 kB
/** * cf authentication lifecycle and call scheduling (spec §F3, §F5). * * cf refreshes an OAuth token only once it has expired (`now >= expiry`), per * process and without a lock, so concurrent cf processes near expiry could * race a rotating refresh token. The scheduler therefore: * - runs one serial `cf auth whoami` before any fan-out (it performs any due * refresh and reports `expiresAt`; `tokenValid` is ignored, cf issue #21); * - in profile and default modes, stops dispatching when `now ≥ expiresAt − 60 s`, * lets in-flight calls drain (each is capped at 45 s), sleeps until * `expiresAt + 1 s` and runs up to two serial `whoami` calls, then resumes. * The first performs the single refresh; cf 1.0.0-beta.13 reads the * credential file before its token getter refreshes, so that call prints the * old (passed) expiry and a second serial `whoami` reads the new one; * - on a 401 pauses dispatch, runs one serial `whoami` (plus the re-read when * it reports a passed expiry) and retries once; * - on a 429 pauses every worker for 30 s and retries once; a second 429 * aborts with CF_RATE_LIMITED; * - retries 5xx, network errors and timeouts twice (after 1 s, then 3 s); * - spends the request budget for every API call, including `whoami`. */ import { z } from 'zod'; import { RECOMMENDED_CF_SCOPES } from '../contract.js'; import { LocalCheckupError, ENV_TOKEN_AUTH_HINT } from '../errors.js'; import { classifyCfStderr } from './run-cf.js'; export const PRE_EXPIRY_MARGIN_MS = 60_000; export const RATE_LIMIT_PAUSE_MS = 30_000; export const TRANSIENT_RETRY_DELAYS_MS = [1_000, 3_000]; export const MAX_CF_INVOCATIONS = 6_000; const WhoamiSchema = z.object({ authenticated: z.boolean(), scopes: z.array(z.string().max(128)).max(1_000).optional(), expiresAt: z.union([z.string().max(64), z.number()]).optional(), }).passthrough(); /** Reduce cf's whoami JSON; email, account names and the credential file path are dropped. */ export function parseWhoami(stdout) { let raw; try { raw = JSON.parse(stdout); } catch { return null; } const parsed = WhoamiSchema.safeParse(raw); if (!parsed.success) return null; const summary = { authenticated: parsed.data.authenticated }; if (parsed.data.expiresAt !== undefined) { const time = typeof parsed.data.expiresAt === 'number' ? parsed.data.expiresAt : Date.parse(parsed.data.expiresAt); if (Number.isFinite(time)) summary.expiresAt = new Date(time).toISOString(); } if (parsed.data.scopes) summary.scopes = parsed.data.scopes.filter((scope) => /^[A-Za-z0-9_.:-]{1,64}$/.test(scope)); return summary; } export function isReadOnlyScope(scope) { return /(?:^|[.:])read$/.test(scope) || scope === 'offline' || scope === 'offline_access'; } export function authSourceFor(mode) { return mode === 'profile' ? 'cf_profile' : mode === 'env' ? 'cf_env_token' : 'cf_default_profile'; } /** * Doctor's auth view. In env mode cf reports `scopes` and `expiresAt` from the * active profile's stored OAuth file even while the API token is in use, so * both are ignored: API token permissions cannot be introspected. */ export function doctorAuth(summary, mode, profile) { const base = { source: authSourceFor(mode), profile: mode === 'profile' ? profile : null, authenticated: summary.authenticated }; if (mode === 'env' || !summary.scopes) return { ...base, scopes: 'unknown' }; return { ...base, ...(summary.expiresAt ? { expiresAt: summary.expiresAt } : {}), scopes: 'known', missingRecommendedScopes: RECOMMENDED_CF_SCOPES.filter((scope) => !summary.scopes.includes(scope)), writeScopeCount: summary.scopes.filter((scope) => !isReadOnlyScope(scope)).length, }; } export function authRequiredError(mode, message) { return new LocalCheckupError('CF_AUTH_REQUIRED', message ?? (mode === 'env' ? 'cf could not authenticate with the API token from this shell.' : 'cf is not signed in for this checkup (the profile is missing or its login expired).'), mode === 'env' ? { hint: ENV_TOKEN_AUTH_HINT } : {}); } /** Dispatch gate shared by every worker. */ export class CfScheduler { options; invocations = 0; dispatched = 0; inFlight = 0; drainWaiters = []; gate = null; refreshing = null; expiresAtMs; preExpiryEnabled = true; /** Dispatch sequence number when the first 429 paused the run. */ rateLimitPauseSeq = null; abortError = null; constructor(options) { this.options = options; } get aborted() { return this.abortError; } /** The serial whoami (re-read once if it refreshed) before fan-out. Throws CF_AUTH_REQUIRED when cf is not signed in. */ async start() { let summary = await this.serialWhoami(); this.setExpiry(summary); if (this.expiryHasPassed()) { // This whoami refreshed an expired token but printed the pre-refresh expiry (see the header). summary = await this.serialWhoami(); this.setExpiry(summary); } return summary; } /** Abort the run: no further dispatches, live children are killed. */ abort(error) { if (this.abortError) return; this.abortError = error; void this.options.terminateAll?.(); } throwIfAborted() { if (this.abortError) throw this.abortError; } /** One cf API call with the §F5 retry policy. */ async call(argv, options = {}) { let transientRetries = 0; let authRetried = false; let rateLimitRetried = false; for (;;) { const seq = await this.dispatch(); let result; try { result = await this.options.invoke(argv, options); } finally { this.release(); } this.throwIfAborted(); const failure = toFailure(result); if (!failure) return { ok: true, stdout: result.stdout }; if (failure.kind === 'http' && failure.status === 429) { // The first 429 pauses every worker for 30 s; calls already in flight // when it began join that pause and retry once. A 429 on a call // dispatched after the pause, or a second 429 on the same call, aborts. if (rateLimitRetried || (this.rateLimitPauseSeq !== null && seq > this.rateLimitPauseSeq)) { const error = new LocalCheckupError('CF_RATE_LIMITED', 'Cloudflare rate-limited the collection twice (HTTP 429). No file was written.', { status: 429 }); this.abort(error); throw error; } rateLimitRetried = true; if (this.rateLimitPauseSeq === null) { this.rateLimitPauseSeq = this.dispatched; await this.setGate(() => this.options.clock.sleep(RATE_LIMIT_PAUSE_MS)); } else if (this.gate) { await this.gate; } continue; } if ((failure.kind === 'http' && failure.status === 401) || failure.kind === 'auth') { if (authRetried) return { ok: false, failure }; authRetried = true; await this.refresh('reactive'); continue; } const transient = failure.kind === 'timeout' || failure.kind === 'network' || (failure.kind === 'http' && failure.status >= 500); if (transient && transientRetries < TRANSIENT_RETRY_DELAYS_MS.length) { await this.options.clock.sleep(TRANSIENT_RETRY_DELAYS_MS[transientRetries]); transientRetries++; continue; } return { ok: false, failure }; } } /** Wait for a request slot and an open gate; returns the dispatch sequence number. */ async dispatch() { this.throwIfAborted(); await this.options.budget.acquire(); for (;;) { this.throwIfAborted(); if (this.gate) { await this.gate; continue; } if (this.shouldRefreshBeforeExpiry()) { void this.refresh('expiry'); continue; } if (this.invocations >= (this.options.maxInvocations ?? MAX_CF_INVOCATIONS)) { const error = new LocalCheckupError('ACCOUNT_TOO_LARGE', 'Collection would exceed 6,000 cf requests; this account is too large for a local checkup.'); this.abort(error); throw error; } this.invocations++; this.inFlight++; return ++this.dispatched; } } release() { this.inFlight--; if (this.inFlight === 0) { const waiters = this.drainWaiters; this.drainWaiters = []; for (const resolve of waiters) resolve(); } } drain() { if (this.inFlight === 0) return Promise.resolve(); return new Promise((resolve) => this.drainWaiters.push(resolve)); } shouldRefreshBeforeExpiry() { return this.options.authMode !== 'env' && this.preExpiryEnabled && this.expiresAtMs !== undefined && this.options.clock.now() >= this.expiresAtMs - PRE_EXPIRY_MARGIN_MS; } /** Close the dispatch gate until `work` (queued after any current gate) finishes. Failures abort the run. */ setGate(work) { const previous = this.gate ?? Promise.resolve(); const gate = previous.then(work).catch((error) => { this.abort(error instanceof Error ? error : new Error(String(error))); }).finally(() => { if (this.gate === gate) this.gate = null; }); this.gate = gate; return gate; } /** Pause dispatch, drain in-flight calls, run up to two serial whoami calls, resume. */ refresh(reason) { if (this.refreshing) return this.refreshing; const refreshing = this.setGate(async () => { await this.drain(); if (reason === 'expiry' && this.expiresAtMs !== undefined) { const wait = this.expiresAtMs + 1_000 - this.options.clock.now(); if (wait > 0) await this.options.clock.sleep(wait); } const previous = this.expiresAtMs; this.setExpiry(await this.serialWhoami()); // cf 1.0.0-beta.13 `auth whoami` reads the credential file before its token getter // refreshes, so the whoami that performs the refresh prints the old expiry. Re-read once // (also after a 401, so a passed expiry does not trigger a second pause straight away). if (this.expiryHasPassed()) { this.setExpiry(await this.serialWhoami()); } // A refresh that did not move the expiry forward falls back to the reactive 401 path. if (reason === 'expiry' && (this.expiresAtMs === undefined || this.expiresAtMs === previous || this.expiresAtMs - PRE_EXPIRY_MARGIN_MS <= this.options.clock.now())) { this.preExpiryEnabled = false; } }).finally(() => { if (this.refreshing === refreshing) this.refreshing = null; }); this.refreshing = refreshing; return refreshing; } expiryHasPassed() { return this.expiresAtMs !== undefined && this.expiresAtMs <= this.options.clock.now(); } setExpiry(summary) { if (this.options.authMode === 'env') return; const time = summary.expiresAt === undefined ? Number.NaN : Date.parse(summary.expiresAt); this.expiresAtMs = Number.isFinite(time) ? time : undefined; } async serialWhoami() { this.throwIfAborted(); await this.options.budget.acquire(); this.invocations++; const result = await this.options.whoami(); if (result.exitCode === 0 && !result.timedOut && !result.stdoutTooLarge) { const summary = parseWhoami(result.stdout); if (!summary) throw new LocalCheckupError('CF_ERROR', 'cf auth whoami printed unexpected output.'); if (!summary.authenticated) throw authRequiredError(this.options.authMode); return summary; } const failure = toFailure(result); if (failure?.kind === 'auth' || (failure?.kind === 'http' && (failure.status === 401 || failure.status === 403))) { throw authRequiredError(this.options.authMode); } if (failure?.kind === 'http' && failure.status === 429) { throw new LocalCheckupError('CF_RATE_LIMITED', 'Cloudflare rate-limited cf auth whoami (HTTP 429).', { status: 429 }); } throw new LocalCheckupError('CF_ERROR', 'cf auth whoami failed; Cloudflare could not be reached or returned an error.'); } } /** `null` for a clean exit; otherwise the classified failure. */ export function toFailure(result) { if (result.stdoutTooLarge) return { kind: 'output_too_large' }; if (result.timedOut) return { kind: 'timeout' }; if (result.spawnFailed) return { kind: 'cf_error' }; if (result.exitCode === 0) return null; const classified = classifyCfStderr(result.stderr); if (classified.kind === 'node_unsupported') { throw new LocalCheckupError('NODE_UNSUPPORTED', 'cf refused to run under this Node.js version.'); } return classified; }