@vigilbase/cli
Version:
Vigilbase CLI — security scanners, agent tools, and Vigilbase One from the terminal.
1,621 lines • 77.7 kB
JavaScript
// GENERATED from packages/tools/src/cloudflare-checkup/snapshot/contract.ts — do not edit
/**
* Local Cloudflare Checkup snapshot contract (`vigilbase.cloudflare-checkup.snapshot/1`).
*
* Single source of truth for the snapshot the Vigilbase CLI collects with
* Cloudflare's `cf` CLI and vigilbase.com evaluates with the existing scanner:
* schemas, limits, request keys, projectors, the IP pseudonymiser, the rule-run
* encoder and expander, the credential tripwire, the snapshot serialiser and
* the local-session HTTP contract.
*
* Portability rules (the file is vendored byte-for-byte into packages/cli):
* - imports only `zod`, never a relative module;
* - ES2022 built-ins only, so it compiles under the tools config
* (ES2017 target, bundler resolution) and the CLI config (ES2022, NodeNext);
* - no raw line terminators inside regex literals (TS1161); use `\p{…}` escapes.
*
* Projection principle (spec §D3). A value the scanner prints is kept verbatim,
* or the whole entry becomes `collection_failed/invalid_output`. A value the
* scanner only compares against fixed literals may be mapped to a sentinel that
* cannot equal any of those literals. A value the scanner tests for truthiness
* keeps its truthiness. Projection never truncates; caps fail the entry.
* Line references below are to `packages/tools/src/cloudflare-checkup/scanner.ts`
* before the read-client refactor; the function names are the stable anchor.
*
* Any change to a field read or a projection rule is a new schema version
* (`/2`), and the server then accepts both for at least one CLI minor release.
*/
import { z } from 'zod';
// ============================================================================
// Versions, HTTP contract constants
// ============================================================================
export const SNAPSHOT_SCHEMA_VERSION = 'vigilbase.cloudflare-checkup.snapshot/1';
export const SNAPSHOT_PROJECTION_VERSION = 1;
export const IP_PSEUDONYMIZATION = 'per_prefix_sequential/1';
export const SNAPSHOT_COLLECTOR_NAME = '@vigilbase/cli';
export const SESSION_CODE_REGEX = /^vbcs1_[A-Za-z0-9_-]{66}$/;
export const PRODUCTION_ORIGIN = 'https://vigilbase.com';
export const LOCAL_SESSION_PATH = '/api/v1/tools/cloudflare-checkup/local-session';
export const LOCAL_SNAPSHOT_PATH = '/api/v1/tools/cloudflare-checkup/local-session/snapshot';
/** Before asking for an email, the CLI checks that local checkups are on and what they accept (no auth, no side effects). */
export const LOCAL_AVAILABILITY_PATH = '/api/v1/tools/cloudflare-checkup/local-session/availability';
/** The CLI asks vigilbase.com to email a verification code (no browser, no Turnstile). */
export const LOCAL_REQUEST_PATH = '/api/v1/tools/cloudflare-checkup/local-session/request';
/** The CLI trades the verification token and the emailed code for a session code. */
export const LOCAL_VERIFY_PATH = '/api/v1/tools/cloudflare-checkup/local-session/verify';
/** `vbcv1_` + base64url_nopad(iv[12] ‖ AES-256-GCM(runId[16] ‖ iat[4]) ‖ tag[16]): names a run awaiting its emailed code. */
export const VERIFICATION_TOKEN_REGEX = /^vbcv1_[A-Za-z0-9_-]{64}$/;
/** The six-digit code One emails for a tool run. */
export const EMAIL_CODE_REGEX = /^\d{6}$/;
/** Verification email languages; the CLI picks one from LC_ALL, LC_MESSAGES or LANG. */
export const LOCAL_EMAIL_LOCALES = ['en', 'pt-PT', 'ar'];
export const SNAPSHOT_SHA256_HEADER = 'X-Vigilbase-Snapshot-Sha256';
export const CLI_VERSION_HEADER = 'X-Vigilbase-CLI-Version';
export const CF_SUPPORTED = Object.freeze({
min: '1.0.0-beta.13',
maxExclusive: '2.0.0',
tested: Object.freeze(['1.0.0-beta.13']),
});
export const SNAPSHOT_LIMITS = Object.freeze({
maxBytes: 8_388_608,
defaultServerMaxBytes: 4_194_304,
maxZones: 100,
maxEntries: 12_000,
maxListItems: 5_000,
maxRuleBodies: 2_000,
maxRunsPerBody: 5_000,
maxRulesPerBody: 20_000,
maxRulesExpandedTotal: 400_000,
// Output caps: every expanded issue rule becomes its own `wafAnalysis.issues`
// entry with its id and name, so the result grows with the expansion. Counted
// per issue (a disabled managed block-all rule is two) and in UTF-8 bytes of
// `ruleId` + `ruleName`, summed over every `zone_ruleset` reference.
maxIssueRulesExpandedTotal: 20_000,
maxIssueTextExpandedBytes: 2_097_152,
maxCollectionHours: 6,
maxAgeHours: 24,
maxFutureSkewSeconds: 300,
// cf prints cursor-only lists without the cursor; a list this long may be
// truncated. Lowered to the server's per_page maximum observed in launch gate 1.
rulesetListTruncationAt: 100,
});
// The 19 zone settings the scanner reads (scanZone 664-732; phase functions).
export const ZONE_SETTING_IDS = [
'ssl',
'always_use_https',
'min_tls_version',
'tls_1_3',
'security_level',
'email_obfuscation',
'hotlink_protection',
'browser_check',
'origin_pulls',
'challenge_ttl',
'browser_cache_ttl',
'minify',
'early_hints',
'rocket_loader',
'waf',
'automatic_https_rewrites',
'security_header',
'opportunistic_encryption',
'always_online',
];
// Public Cloudflare managed ruleset IDs, segmented like scanner.ts:56-62 to
// avoid credential heuristics.
const seg = (...parts) => parts.join('');
export const MANAGED_RULESET_IDS = Object.freeze([
seg('efb7b8c949ac4650', 'a09736fc376e9aee'),
seg('4814384a9e5d4991', 'b9815dcfc25d2f1f'),
seg('c2e184081120413c', '86c3ab7e14069605'),
seg('e22d83c647c64a3e', 'ae91b71b499d988e'),
]);
// Lower-cased managed ruleset names `hasExecutedRuleset` searches for (2412-2429).
export const MANAGED_NAME_NEEDLES = [
'cloudflare managed ruleset',
'owasp core ruleset',
'exposed credentials check',
'sensitive data detection',
];
// `LEGACY_PAGE_RULE_ACTIONS` (scanner.ts:64-70).
export const LEGACY_PAGE_RULE_ACTIONS = [
'disable_security',
'disable_performance',
'waf',
'security_level',
'browser_check',
];
export const ACCOUNT_KINDS = [
'account',
'zones',
'account_members',
'account_rulesets',
'account_access_rules',
'access_apps',
'access_identity_providers',
'access_service_tokens',
'gateway_rules',
'gateway_locations',
'cfd_tunnels',
];
export const ZONE_SINGLE_KINDS = [
'zone_dnssec',
'zone_dns_records',
'zone_universal_ssl',
'zone_total_tls',
'zone_rulesets',
'zone_access_rules',
'zone_page_rules',
'zone_bot_management',
'zone_page_shield',
'zone_schema_validation_settings',
'zone_schema_validation_schemas',
];
// cf 1.0.0-beta.13 has no command for these reads; they are always `not_collected`.
export const NOT_COLLECTABLE_ZONE_KINDS = [
'zone_legacy_firewall_rules',
'zone_legacy_filters',
'zone_legacy_waf_packages',
'zone_legacy_rate_limits',
'zone_waf_analytics',
];
export const SNAPSHOT_KINDS = [
...ACCOUNT_KINDS,
'zone_setting',
...ZONE_SINGLE_KINDS,
'zone_ruleset',
...NOT_COLLECTABLE_ZONE_KINDS,
];
export const COLLECTION_FAILURE_DETAILS = [
'cf_error',
'invalid_output',
'timeout',
'output_too_large',
'possibly_truncated',
'page_limit',
'command_unavailable',
];
// Scope names verified in cf beta.13; their sufficiency is launch gate 1.
export const RECOMMENDED_CF_SCOPES = [
'account:read',
'account-settings.read',
'memberships.read',
'user:read',
'user-details.read',
'zone:read',
'zone.read',
'zone-settings.read',
'dns.read',
'dns_records:read',
'ssl-and-certificates.read',
'zone-waf.read',
'account-waf.read',
'account-rulesets.read',
'http-ddos-managed-ruleset.read',
'firewall-services.read',
'account-firewall-access-rules.read',
'page-rules.read',
'bot-management.read',
'page-shield.read',
'domain-page-shield.read',
'api-gateway.read',
'access.read',
'access:read',
'access-app.read',
'access-idp.read',
'access-service-token.read',
'teams.read',
'teams:read',
'argotunnel.read',
'teams-connector-cloudflared.read',
];
// ============================================================================
// Versions
// ============================================================================
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.]+))?$/;
/**
* SemVer 2 precedence. Returns a negative number, zero or a positive number,
* or `NaN` when either side is not `MAJOR.MINOR.PATCH[-PRERELEASE]`.
*/
export function compareSemver(a, b) {
const left = SEMVER_RE.exec(a);
const right = SEMVER_RE.exec(b);
if (!left || !right)
return Number.NaN;
for (let i = 1; i <= 3; i++) {
const diff = Number(left[i]) - Number(right[i]);
if (diff !== 0)
return diff;
}
const leftPre = left[4];
const rightPre = right[4];
if (leftPre === undefined || rightPre === undefined) {
if (leftPre === rightPre)
return 0;
return leftPre === undefined ? 1 : -1;
}
const leftIds = leftPre.split('.');
const rightIds = rightPre.split('.');
for (let i = 0; i < Math.min(leftIds.length, rightIds.length); i++) {
const l = leftIds[i];
const r = rightIds[i];
if (l === r)
continue;
const lNum = /^\d+$/.test(l);
const rNum = /^\d+$/.test(r);
if (lNum && rNum)
return Number(l) - Number(r);
if (lNum !== rNum)
return lNum ? -1 : 1;
return l < r ? -1 : 1;
}
return leftIds.length - rightIds.length;
}
/**
* True when `version` lies in `[CF_SUPPORTED.min, CF_SUPPORTED.maxExclusive)`.
* The upper bound compares release cores only, so a `2.0.0-beta.1` preview of
* the next major is unsupported too.
*/
export function isSupportedCfVersion(version) {
const core = SEMVER_RE.exec(version);
if (!core)
return false;
const atLeastMin = compareSemver(version, CF_SUPPORTED.min) >= 0;
const belowMax = compareSemver(`${core[1]}.${core[2]}.${core[3]}`, CF_SUPPORTED.maxExclusive) < 0;
return atLeastMin && belowMax;
}
// ============================================================================
// Primitive schemas
// ============================================================================
// Control (Cc), format/bidi (Cf), line separator (Zl = LS), paragraph separator (Zp = PS)
// and lone UTF-16 surrogates (Cs; a `u` regex sees a valid pair as one astral code point).
// Postgres jsonb rejects a lone surrogate escape, so one would strand the run in One.
// Property escapes only: a raw LS/PS inside a regex literal is a SyntaxError (TS1161).
// Not `String.prototype.isWellFormed()`: it is ES2024 and the CLI copy compiles against ES2022.
const CTRL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/u;
const TOKEN_RE = /^[A-Za-z0-9_.:-]+$/;
export const HEX32_REGEX = /^[0-9a-f]{32}$/;
const IP_CONTENT_RE = /^[0-9A-Fa-f:.]+$/;
const ACCESS_VALUE_RE = /^[A-Za-z0-9.:/_*-]+$/;
const RULE_ACTION_RE = /^[A-Za-z_]{1,32}$/;
const GEOIP_BLOCK_RE = /ip\.geoip\.country\s+(eq|in)/;
const text = (max) => z.string().max(max).refine((v) => !CTRL.test(v), 'control_or_format_character');
const token = (max = 64) => z.string().min(1).max(max).regex(TOKEN_RE);
const hex32 = z.string().regex(HEX32_REGEX);
const hostname = z.string().min(1).max(253).regex(/^[A-Za-z0-9*_.-]+$/);
const semver = z.string().max(40).regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/);
const iso = z.iso.datetime({ offset: true });
const scalar = z.union([token(64), z.literal(''), z.number().finite(), z.boolean(), z.null()]);
const email = z.string().max(254).regex(/^[^\s@\p{Cc}\p{Cf}\p{Cs}]+@[^\s@\p{Cc}\p{Cf}\p{Cs}]+$/u); // \s covers LS/PS
const accessValue = z.string().min(1).max(128).regex(ACCESS_VALUE_RE);
const bodyRef = z.string().regex(/^r[0-9]{1,5}$/);
const httpError = z.object({ ok: z.literal(false), reason: z.literal('http_error'), status: z.number().int().min(400).max(599) }).strict();
const notCollected = z.object({ ok: z.literal(false), reason: z.literal('not_collected') }).strict();
const collectionFailed = z.object({ ok: z.literal(false), reason: z.literal('collection_failed'), detail: z.enum(COLLECTION_FAILURE_DETAILS) }).strict();
const ok = (data) => z.object({ ok: z.literal(true), data }).strict();
const result = (data) => z.union([ok(data), httpError, collectionFailed]);
const list = (item, max = SNAPSHOT_LIMITS.maxListItems) => z.array(item).max(max);
const counted = z.object({ count: z.number().int().min(0).max(SNAPSHOT_LIMITS.maxListItems) }).strict();
const enabledOnly = z.object({ enabled: z.boolean().optional() }).strict();
// ============================================================================
// Entry data schemas
// ============================================================================
export const AccountData = z.object({ id: hex32, name: text(256), type: token(32).optional(),
settings: z.object({ enforce_twofactor: z.boolean().optional() }).strict().optional() }).strict();
export const ZoneData = z.object({ id: hex32, name: hostname, status: token(32).optional(), paused: z.boolean().optional(),
type: token(32).optional(), development_mode: z.number().int().optional(),
plan: z.object({ name: text(64) }).strict().optional(), account: z.object({ id: hex32, name: text(256) }).strict() }).strict();
export const MemberData = z.object({ status: z.literal('pending').optional(), email: email.optional(),
user: z.object({ two_factor_authentication_enabled: z.literal(false) }).strict().optional() }).strict();
export const AccessRuleData = z.object({ mode: token(32).optional(),
configuration: z.object({ target: token(32).optional(), value: accessValue.optional() }).strict().optional() }).strict();
export const IdentityProviderData = z.object({ type: token(64).optional() }).strict();
export const ServiceTokenData = z.object({ expires_at: iso.optional() }).strict();
export const GatewayRuleData = z.object({ enabled: z.literal(false).optional(), filters: z.array(token(32)).max(16).optional() }).strict();
export const TunnelData = z.object({ status: token(32).optional(), is_deleted: z.literal(true).optional() }).strict();
export const SettingData = z.object({ value: z.union([scalar,
z.record(token(64), z.union([scalar, z.record(token(64), scalar)]))]).optional() }).strict();
export const DnssecData = z.object({ status: token(32).optional(), algorithm: z.union([token(16), z.number().int()]).optional() }).strict();
export const DnsRecordData = z.object({ type: z.enum(['A', 'AAAA']), name: hostname.optional(),
content: z.string().min(1).max(64).regex(IP_CONTENT_RE), proxied: z.literal(true).optional() }).strict();
export const RulesetHeadData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional() }).strict();
export const RulesetDetailData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional(),
rulesBody: bodyRef.optional() }).strict();
export const PageRuleData = z.object({ actions: z.array(z.object({ id: z.enum(LEGACY_PAGE_RULE_ACTIONS) }).strict()).max(32).optional() }).strict();
// `stale_zone_configuration` is its key list in API order: the scanner prints
// `Object.keys(...)`, and the serialiser sorts object keys, so a `{key: true}`
// object would reach the server in alphabetical order.
const staleKeyList = z.array(token(64)).max(64).refine((keys) => new Set(keys).size === keys.length, 'duplicate_stale_key');
export const BotManagementData = z.record(token(64), z.union([scalar, staleKeyList]))
.refine((o) => Object.keys(o).length <= 64 && Object.entries(o).every(([k, v]) => k === 'stale_zone_configuration' || v === null || typeof v !== 'object'), 'only_stale_zone_configuration_may_be_a_list')
.refine((o) => ['fight_mode', 'enable_js'].every((k) => !(k in o) || typeof o[k] === 'boolean'), 'truthiness_keys_are_booleans');
export const SchemaValidationSettingsData = z.record(token(64), scalar).refine((o) => Object.keys(o).length <= 64, 'too_many_keys');
export const CountedData = counted;
export const EnabledOnlyData = enabledOnly;
const needleJoin = z.string().max(200).refine((v) => {
const parts = v.split(' | ');
return parts.every((x) => MANAGED_NAME_NEEDLES.includes(x)) && new Set(parts).size === parts.length;
});
const ruleShape = {
action: z.string().regex(RULE_ACTION_RE).optional(), // non-conforming raw actions become 'other'
enabled: z.literal(false).optional(),
expression: z.enum(['true', 'ip.geoip.country in']).optional(),
action_parameters: z.object({ id: z.string().refine((v) => MANAGED_RULESET_IDS.includes(v)).optional(),
name: needleJoin.optional() }).strict().optional(),
id: token(64).optional(),
ref: text(128).optional(),
description: text(500).optional(),
};
export const ProjectedRuleData = z.object(ruleShape).strict();
export const RuleRunData = z.object({
n: z.number().int().min(2).max(SNAPSHOT_LIMITS.maxRulesPerBody).optional(),
...ruleShape,
}).strict();
// Collector-side ruleset detail: rules are projected but not yet encoded into
// `ruleBodies` (`assembleSnapshot` does that once every zone is collected).
export const ProjectedRulesetDetailData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional(),
rules: z.array(ProjectedRuleData).max(SNAPSHOT_LIMITS.maxRulesPerBody) }).strict();
// ============================================================================
// Entries and the snapshot
// ============================================================================
const A = (kind, r) => z.object({ kind: z.literal(kind), result: r }).strict();
const Z = (kind, r) => z.object({ kind: z.literal(kind), zoneId: hex32, result: r }).strict();
export const SnapshotEntrySchema = z.discriminatedUnion('kind', [
A('account', z.union([ok(AccountData), httpError])), // the scanner tolerates 400/401/403/404 (runInitPhase 4369-4378); other failures abort collection
A('zones', ok(list(ZoneData, SNAPSHOT_LIMITS.maxZones))),
A('account_members', result(list(MemberData))),
A('account_rulesets', result(counted)),
A('account_access_rules', result(list(AccessRuleData))),
A('access_apps', result(counted)),
A('access_identity_providers', result(list(IdentityProviderData))),
A('access_service_tokens', result(list(ServiceTokenData))),
A('gateway_rules', result(list(GatewayRuleData))),
A('gateway_locations', result(counted)),
A('cfd_tunnels', result(list(TunnelData))),
z.object({ kind: z.literal('zone_setting'), zoneId: hex32, settingId: z.enum(ZONE_SETTING_IDS), result: result(SettingData) }).strict(),
Z('zone_dnssec', result(DnssecData)),
Z('zone_dns_records', result(list(DnsRecordData))),
Z('zone_universal_ssl', result(enabledOnly)),
Z('zone_total_tls', result(enabledOnly)),
Z('zone_rulesets', result(z.array(RulesetHeadData).max(200))),
// A failed detail is never written: the collector fails the zone's zone_rulesets instead (§D1, §F5).
z.object({ kind: z.literal('zone_ruleset'), zoneId: hex32, rulesetId: hex32,
result: z.union([ok(RulesetDetailData), httpError]) }).strict(),
Z('zone_access_rules', result(list(AccessRuleData))),
Z('zone_page_rules', result(list(PageRuleData))),
Z('zone_bot_management', result(BotManagementData)),
Z('zone_page_shield', result(enabledOnly)),
Z('zone_schema_validation_settings', result(SchemaValidationSettingsData)),
Z('zone_schema_validation_schemas', result(counted)),
Z('zone_legacy_firewall_rules', notCollected),
Z('zone_legacy_filters', notCollected),
Z('zone_legacy_waf_packages', notCollected),
Z('zone_legacy_rate_limits', notCollected),
Z('zone_waf_analytics', notCollected),
]);
export const SnapshotCollectorSchema = z.object({
name: z.literal(SNAPSHOT_COLLECTOR_NAME), version: semver, cf: z.object({ version: semver }).strict(),
authSource: z.enum(['cf_profile', 'cf_env_token', 'cf_default_profile']), // auth KIND only, never a credential
platform: z.enum(['darwin', 'linux', 'win32']),
startedAt: iso, finishedAt: iso, cfInvocations: z.number().int().min(0).max(50_000),
}).strict();
const SnapshotObjectSchema = z.object({
schemaVersion: z.literal(SNAPSHOT_SCHEMA_VERSION),
projection: z.object({ version: z.literal(SNAPSHOT_PROJECTION_VERSION), ipPseudonymization: z.literal(IP_PSEUDONYMIZATION) }).strict(),
accountId: hex32,
collector: SnapshotCollectorSchema,
entries: z.array(SnapshotEntrySchema).min(2).max(SNAPSHOT_LIMITS.maxEntries),
ruleBodies: z.record(bodyRef, z.array(RuleRunData).min(1).max(SNAPSHOT_LIMITS.maxRunsPerBody)),
}).strict();
export const CloudflareCheckupSnapshotV1Schema = SnapshotObjectSchema.superRefine(validateSnapshotSemantics);
const MAX_REPEATED_ISSUES = 20;
const ACCOUNT_TOLERATED_STATUSES = [400, 401, 403, 404];
/**
* Cross-entry rules the per-entry schema cannot express. Runs as the
* snapshot schema's `superRefine`; every issue carries `params.reason`, which
* `snapshotRejectReason` maps to the HTTP reason. The time window relative to
* the server clock is checked in com, not here.
*/
export function validateSnapshotSemantics(snapshot, ctx) {
const counts = new Map();
const issue = (message, path, reason = 'snapshot_invalid') => {
const seen = counts.get(message) ?? 0;
counts.set(message, seen + 1);
if (seen < MAX_REPEATED_ISSUES)
ctx.addIssue({ code: 'custom', message, path, params: { reason } });
};
const { accountId, entries, ruleBodies } = snapshot;
// 1. Exactly one account and one zones entry; the account failure is a tolerated status.
const accountEntries = entries.filter((e) => e.kind === 'account');
const zonesEntries = entries.filter((e) => e.kind === 'zones');
if (accountEntries.length !== 1)
issue('account_entry_count', ['entries'], 'snapshot_incomplete');
if (zonesEntries.length !== 1)
issue('zones_entry_count', ['entries'], 'snapshot_incomplete');
entries.forEach((entry, i) => {
if (entry.kind !== 'account')
return;
if (entry.result.ok) {
if (entry.result.data.id !== accountId)
issue('account_id_mismatch', ['entries', i, 'result', 'data', 'id']);
}
else if (!ACCOUNT_TOLERATED_STATUSES.includes(entry.result.status)) {
issue('account_failure_status', ['entries', i, 'result', 'status']);
}
});
// 2. Zones: unique, owned by this account.
const zonesEntry = zonesEntries[0];
const zones = zonesEntry?.kind === 'zones' ? zonesEntry.result.data : [];
const zoneIds = zones.map((zone) => zone.id);
const zoneIdSet = new Set(zoneIds);
if (zoneIdSet.size !== zoneIds.length)
issue('zone_duplicate', ['entries']);
if (zoneIds.length > SNAPSHOT_LIMITS.maxZones)
issue('too_many_zones', ['entries']);
zones.forEach((zone, z) => {
if (zone.account.id !== accountId) {
const index = entries.indexOf(zonesEntry);
issue('zone_account_mismatch', ['entries', index, 'result', 'data', z, 'account', 'id']);
}
});
// 3. Exact key set.
const listedRulesets = {};
const failedRulesetLists = new Set();
const phaseByRulesetRef = new Map();
for (const entry of entries) {
if (entry.kind !== 'zone_rulesets' || !zoneIdSet.has(entry.zoneId))
continue;
if (entry.result.ok)
listedRulesets[entry.zoneId] = entry.result.data.map((head) => head.id);
else
failedRulesetLists.add(entry.zoneId);
}
const expected = new Set(expectedEntryKeys(accountId, zoneIds, listedRulesets));
const seen = new Set();
entries.forEach((entry, i) => {
if ('zoneId' in entry && !zoneIdSet.has(entry.zoneId)) {
issue('foreign_zone', ['entries', i, 'zoneId'], 'snapshot_incomplete');
return;
}
if (entry.kind === 'zone_ruleset') {
if (failedRulesetLists.has(entry.zoneId)) {
issue('ruleset_under_failed_list', ['entries', i], 'snapshot_incomplete');
return;
}
if (!(listedRulesets[entry.zoneId] ?? []).includes(entry.rulesetId)) {
issue('unlisted_ruleset', ['entries', i, 'rulesetId'], 'snapshot_incomplete');
return;
}
if (entry.result.ok) {
if (entry.result.data.id !== entry.rulesetId)
issue('ruleset_id_mismatch', ['entries', i, 'result', 'data', 'id']);
const ref = entry.result.data.rulesBody;
if (ref !== undefined) {
const phases = phaseByRulesetRef.get(ref) ?? [];
phases.push(entry.result.data.phase);
phaseByRulesetRef.set(ref, phases);
}
}
}
const key = snapshotEntryKey(entry, accountId);
if (seen.has(key))
issue('duplicate_entry', ['entries', i], 'snapshot_incomplete');
else if (!expected.has(key))
issue('unexpected_entry', ['entries', i], 'snapshot_incomplete');
seen.add(key);
});
for (const key of expected) {
if (!seen.has(key))
issue('missing_entry', ['entries'], 'snapshot_incomplete');
}
// 4. Rule bodies: bounded, all resolved, none unreferenced, global expansion cap.
const bodyRefs = Object.keys(ruleBodies);
if (bodyRefs.length > SNAPSHOT_LIMITS.maxRuleBodies)
issue('too_many_rule_bodies', ['ruleBodies']);
const expandedByRef = new Map();
for (const ref of bodyRefs) {
const runs = ruleBodies[ref];
const expanded = runs.reduce((sum, run) => sum + (run.n ?? 1), 0);
expandedByRef.set(ref, expanded);
if (expanded > SNAPSHOT_LIMITS.maxRulesPerBody)
issue('rule_body_too_large', ['ruleBodies', ref]);
runs.forEach((run, j) => {
const path = ['ruleBodies', ref, j];
const action = run.action?.toLowerCase();
if (run.expression !== undefined && action !== 'block')
issue('expression_on_non_block_rule', [...path, 'expression']);
if (run.action_parameters !== undefined && run.action !== 'execute') {
issue('action_parameters_on_non_execute_rule', [...path, 'action_parameters']);
}
if (run.ref !== undefined && run.description)
issue('ref_with_description', [...path, 'ref']);
const carriesDetail = run.id !== undefined || run.description !== undefined || run.ref !== undefined;
if (carriesDetail && (phaseByRulesetRef.get(ref) ?? []).some((phase) => !isIssueRule(run, phase))) {
issue('detail_on_non_issue_rule', path);
}
});
}
// The evaluation output grows with the expansion too: every expanded issue
// rule becomes its own `wafAnalysis.issues` entry carrying its id and name.
let expandedTotal = 0;
let issuesTotal = 0;
let issueTextBytes = 0;
const issueLoadByRefPhase = new Map();
entries.forEach((entry, i) => {
if (entry.kind !== 'zone_ruleset' || !entry.result.ok)
return;
const ref = entry.result.data.rulesBody;
if (ref === undefined)
return;
if (!Object.prototype.hasOwnProperty.call(ruleBodies, ref)) {
issue('rule_body_unresolved', ['entries', i, 'result', 'data', 'rulesBody']);
return;
}
expandedTotal += expandedByRef.get(ref) ?? 0;
// A phase matters only through `includes('managed')`, so each body is scanned at most twice.
const managedPhase = isManagedPhase(entry.result.data.phase);
const loadKey = `${ref}|${managedPhase ? 'managed' : 'other'}`;
let load = issueLoadByRefPhase.get(loadKey);
if (load === undefined) {
load = wafIssueLoad(ruleBodies[ref], managedPhase);
issueLoadByRefPhase.set(loadKey, load);
}
issuesTotal += load.issues;
issueTextBytes += load.textBytes;
});
for (const ref of bodyRefs) {
if (!phaseByRulesetRef.has(ref))
issue('rule_body_unreferenced', ['ruleBodies', ref]);
}
if (expandedTotal > SNAPSHOT_LIMITS.maxRulesExpandedTotal)
issue('expanded_rules_cap', ['ruleBodies']);
if (issuesTotal > SNAPSHOT_LIMITS.maxIssueRulesExpandedTotal)
issue('expanded_issue_rules_cap', ['ruleBodies']);
if (issueTextBytes > SNAPSHOT_LIMITS.maxIssueTextExpandedBytes)
issue('expanded_issue_text_cap', ['ruleBodies']);
// 5. Timestamps: startedAt <= finishedAt <= startedAt + 6 h.
const started = Date.parse(snapshot.collector.startedAt);
const finished = Date.parse(snapshot.collector.finishedAt);
if (!(started <= finished) || finished - started > SNAPSHOT_LIMITS.maxCollectionHours * 3_600_000) {
issue('collection_window', ['collector', 'finishedAt']);
}
// 6. Credential tripwire over every string (and record key) in the snapshot.
walkStrings(snapshot, [], (value, path) => {
if (containsSecretLikeValue(value))
issue('secret_like_value', path, 'secret_like_value');
});
}
/**
* The `wafAnalysis.issues` one reference to a rule body produces under a
* ruleset phase, and the UTF-8 bytes of the rule text those issues print
* (`ruleId` and `ruleName = description || ref`).
*/
function wafIssueLoad(runs, managedPhase) {
let issues = 0;
let textBytes = 0;
for (const run of runs) {
const perRule = wafIssueCount(run, managedPhase);
if (perRule === 0)
continue;
const count = (run.n ?? 1) * perRule;
issues += count;
textBytes += count * (utf8Length(run.id ?? '') + utf8Length(run.description || run.ref || ''));
}
return { issues, textBytes };
}
/** UTF-8 length of a string (a lone surrogate counts as the 3-byte replacement character). */
function utf8Length(value) {
let bytes = 0;
for (let i = 0; i < value.length; i++) {
const unit = value.charCodeAt(i);
if (unit < 0x80)
bytes += 1;
else if (unit < 0x800)
bytes += 2;
else if (unit >= 0xd800 && unit <= 0xdbff && i + 1 < value.length && (value.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
bytes += 4;
i++;
}
else
bytes += 3;
}
return bytes;
}
const SESSION_CODE_LIKE_RE = /vbc[sv]1_[A-Za-z0-9_-]{20,}/;
const JWT_LIKE_RE = /eyJ[\w-]{10,}\.[\w-]{10,}\./;
const PEM_LIKE_RE = /-----BEGIN/;
const BEARER_LIKE_RE = /Bearer\s+\S{20,}/;
const TOKEN_CHARSET_RUN_RE = /[A-Za-z0-9_-]{40,}/g;
/**
* Credential tripwire (§D1.6): a Vigilbase session code or verification token, a 40-character
* `[A-Za-z0-9_-]` window with upper case, lower case and a digit (the
* Cloudflare API token shape), a JWT, a PEM header or a bearer header.
*/
export function containsSecretLikeValue(value) {
if (SESSION_CODE_LIKE_RE.test(value) || JWT_LIKE_RE.test(value) || PEM_LIKE_RE.test(value) || BEARER_LIKE_RE.test(value)) {
return true;
}
for (const match of value.match(TOKEN_CHARSET_RUN_RE) ?? []) {
if (hasMixedClassWindow(match, 40))
return true;
}
return false;
}
function hasMixedClassWindow(run, width) {
let upper = 0;
let lower = 0;
let digit = 0;
const classOf = (c) => (c >= 'A' && c <= 'Z' ? 1 : c >= 'a' && c <= 'z' ? 2 : c >= '0' && c <= '9' ? 3 : 0);
const bump = (c, delta) => {
const k = classOf(c);
if (k === 1)
upper += delta;
else if (k === 2)
lower += delta;
else if (k === 3)
digit += delta;
};
for (let i = 0; i < run.length; i++) {
bump(run[i], 1);
if (i >= width)
bump(run[i - width], -1);
if (i >= width - 1 && upper > 0 && lower > 0 && digit > 0)
return true;
}
return false;
}
function walkStrings(value, path, visit) {
if (typeof value === 'string') {
visit(value, path);
}
else if (Array.isArray(value)) {
value.forEach((item, i) => walkStrings(item, [...path, i], visit));
}
else if (value !== null && typeof value === 'object') {
for (const [key, item] of Object.entries(value)) {
visit(key, [...path, key]);
walkStrings(item, [...path, key], visit);
}
}
}
/**
* Map the issues of a failed `CloudflareCheckupSnapshotV1Schema.safeParse` to
* the HTTP reason: the tripwire wins, then key-set errors, then everything else.
*/
export function snapshotRejectReason(issues) {
const reasons = new Set(issues.map((i) => i.params?.reason));
if (reasons.has('secret_like_value'))
return 'secret_like_value';
if (reasons.has('snapshot_incomplete'))
return 'snapshot_incomplete';
return 'snapshot_invalid';
}
/**
* The `issues` array of a local-session error response: at most 20
* `{path, code}` pairs and never a value from the snapshot.
*/
export function snapshotIssueList(issues) {
return issues.slice(0, 20).map((i) => ({
path: (i.path ?? []).map((p) => String(p)).join('.').slice(0, 200),
code: String((i.code === 'custom' ? i.message : i.code) ?? 'invalid').slice(0, 64),
}));
}
/** The scanner REST call a snapshot entry answers. The replay client derives the same key per call. */
export function snapshotRequestKey(kind, ids) {
const a = ids.accountId;
const z = ids.zoneId ?? '';
switch (kind) {
case 'account': return `GET /accounts/${a}`;
case 'zones': return `GET /zones?account.id=${a}`;
case 'account_members': return `GET /accounts/${a}/members`;
case 'account_rulesets': return `GET /accounts/${a}/rulesets`;
case 'account_access_rules': return `GET /accounts/${a}/firewall/access_rules/rules`;
case 'access_apps': return `GET /accounts/${a}/access/apps`;
case 'access_identity_providers': return `GET /accounts/${a}/access/identity_providers`;
case 'access_service_tokens': return `GET /accounts/${a}/access/service_tokens`;
case 'gateway_rules': return `GET /accounts/${a}/gateway/rules`;
case 'gateway_locations': return `GET /accounts/${a}/gateway/locations`;
case 'cfd_tunnels': return `GET /accounts/${a}/cfd_tunnel?is_deleted=false`;
case 'zone_setting': return `GET /zones/${z}/settings/${ids.settingId ?? ''}`;
case 'zone_dnssec': return `GET /zones/${z}/dnssec`;
case 'zone_dns_records': return `GET /zones/${z}/dns_records`;
case 'zone_universal_ssl': return `GET /zones/${z}/ssl/universal/settings`;
case 'zone_total_tls': return `GET /zones/${z}/acm/total_tls`;
case 'zone_rulesets': return `GET /zones/${z}/rulesets`;
case 'zone_ruleset': return `GET /zones/${z}/rulesets/${ids.rulesetId ?? ''}`;
case 'zone_access_rules': return `GET /zones/${z}/firewall/access_rules/rules`;
case 'zone_page_rules': return `GET /zones/${z}/pagerules`;
case 'zone_bot_management': return `GET /zones/${z}/bot_management`;
case 'zone_page_shield': return `GET /zones/${z}/page_shield`;
case 'zone_schema_validation_settings': return `GET /zones/${z}/schema_validation/settings`;
case 'zone_schema_validation_schemas': return `GET /zones/${z}/schema_validation/schemas`;
case 'zone_legacy_firewall_rules': return `GET /zones/${z}/firewall/rules`;
case 'zone_legacy_filters': return `GET /zones/${z}/filters`;
case 'zone_legacy_waf_packages': return `GET /zones/${z}/firewall/waf/packages`;
case 'zone_legacy_rate_limits': return `GET /zones/${z}/rate_limits`;
case 'zone_waf_analytics': return `POST /graphql#firewallEventsAdaptiveGroups/${z}`;
}
}
export function snapshotEntryKey(entry, accountId) {
return snapshotRequestKey(entry.kind, {
accountId,
zoneId: entry.zoneId,
settingId: entry.settingId,
rulesetId: entry.rulesetId,
});
}
/**
* Every key a complete snapshot holds, in canonical entry order: account kinds,
* then per zone (in list order) the 19 settings, the single kinds with each
* listed ruleset detail right after `zone_rulesets`, then the not-collectable
* kinds. `rulesetIdsByZone` holds only zones whose `zone_rulesets` is ok.
*/
export function expectedEntryKeys(accountId, zones, rulesetIdsByZone) {
const keys = ACCOUNT_KINDS.map((kind) => snapshotRequestKey(kind, { accountId }));
for (const zone of zones) {
const zoneId = typeof zone === 'string' ? zone : zone.id;
for (const settingId of ZONE_SETTING_IDS)
keys.push(snapshotRequestKey('zone_setting', { accountId, zoneId, settingId }));
for (const kind of ZONE_SINGLE_KINDS) {
keys.push(snapshotRequestKey(kind, { accountId, zoneId }));
if (kind !== 'zone_rulesets')
continue;
const seen = new Set();
for (const rulesetId of rulesetIdsByZone[zoneId] ?? []) {
if (seen.has(rulesetId))
continue;
seen.add(rulesetId);
keys.push(snapshotRequestKey('zone_ruleset', { accountId, zoneId, rulesetId }));
}
}
for (const kind of NOT_COLLECTABLE_ZONE_KINDS)
keys.push(snapshotRequestKey(kind, { accountId, zoneId }));
}
return keys;
}
class ProjectionFailure {
detail;
constructor(detail) {
this.detail = detail;
}
}
const invalid = () => {
throw new ProjectionFailure('invalid_output');
};
const tooLarge = () => {
throw new ProjectionFailure('output_too_large');
};
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
function rawObject(raw) {
return isObject(raw) ? raw : invalid();
}
function rawItems(raw, max = SNAPSHOT_LIMITS.maxListItems) {
if (!Array.isArray(raw))
return invalid();
if (raw.length > max)
return tooLarge();
return raw.map((item) => rawObject(item));
}
function runProjector(schema, build) {
try {
const data = build();
// Every printed value is copied verbatim; anything the schema cannot
// represent fails the entry instead of being silently altered.
return schema.safeParse(data).success ? { ok: true, data } : { ok: false, detail: 'invalid_output' };
}
catch (error) {
if (error instanceof ProjectionFailure)
return { ok: false, detail: error.detail };
throw error;
}
}
const isToken = (value, max) => typeof value === 'string' && value.length <= max && TOKEN_RE.test(value);
/** Compared-only strings: tokens verbatim, `''` stays `''` (falsy), anything else becomes `'other'` (truthy). */
const comparedString = (value) => (isToken(value, 64) ? value : value === '' ? '' : 'other');
/** A field the scanner prints with an `x || fallback`: falsy values are omitted, the rest copied verbatim. */
const printedIfTruthy = (value) => (value ? value : undefined);
function assignDefined(target, values) {
for (const [key, value] of Object.entries(values)) {
if (value !== undefined)
target[key] = value;
}
return target;
}
/**
* `account`: `id, name, type, settings.enforce_twofactor`. createAccountMfaCheck
* (1110) compares `enforce_twofactor === true`; runInitPhase (4369-4381) and
* assembleCheckupResult (5127-5136) print `name`, `type` and `enforce_twofactor`.
*/
export function projectAccount(raw) {
return runProjector(AccountData, () => {
const account = rawObject(raw);
const settings = isObject(account.settings) ? account.settings : undefined;
const out = assignDefined({}, { id: account.id, name: account.name, type: account.type });
if (settings && settings.enforce_twofactor !== undefined) {
out.settings = { enforce_twofactor: settings.enforce_twofactor };
}
return out;
});
}
/**
* `zones` (all pages merged, API order): `id, name, status, paused, type,
* development_mode, plan.name, account.{id,name}`. zoneCtxFor (4338) prints the
* account; createZoneStatusCheck (1960-1990), createDevModeCheck (2165-2197),
* createDNSModeCheck (3218-3234) and assembleCheckupResult (5099-5102) read the rest.
* `paused` is only tested for truthiness.
*/
export function projectZones(raw) {
return runProjector(z.array(ZoneData).max(SNAPSHOT_LIMITS.maxZones), () => rawItems(raw, SNAPSHOT_LIMITS.maxZones).map((zone) => {
const plan = isObject(zone.plan) ? zone.plan : undefined;
const account = isObject(zone.account) ? zone.account : invalid();
const out = assignDefined({}, {
id: zone.id,
name: zone.name,
status: zone.status,
paused: zone.paused === undefined || typeof zone.paused === 'boolean' ? zone.paused : Boolean(zone.paused),
// A null `type` reads as full setup and a null `development_mode` as
// disabled; neither is printed then, so null is the same as absent.
type: zone.type ?? undefined,
development_mode: zone.development_mode ?? undefined,
});
if (plan && printedIfTruthy(plan.name) !== undefined)
out.plan = { name: plan.name };
out.account = { id: account.id, name: account.name };
return out;
}));
}
const MEMBER_EMAIL_LIMIT = 8;
/**
* `account_members`: one item per member, in order. createMemberMfaCheck and
* createPendingMembersCheck (1162-1198, 1224-1252) read only `status ===
* 'pending'`, `two_factor_authentication_enabled === false`, `.length`, and the
* first 8 truthy `m.email || m.user.email` of each selection.
*/
export function projectAccountMembers(raw) {
return runProjector(list(MemberData), () => {
let pendingEmails = 0;
let without2faEmails = 0;
return rawItems(raw).map((member) => {
const out = {};
const user = isObject(member.user) ? member.user : undefined;
const pending = member.status === 'pending';
const without2fa = user?.two_factor_authentication_enabled === false;
if (pending)
out.status = 'pending';
const address = member.email || user?.email;
if (address && pending && pendingEmails < MEMBER_EMAIL_LIMIT) {
out.email = address;
pendingEmails++;
}
else if (address && !pending && without2fa && without2faEmails < MEMBER_EMAIL_LIMIT) {
out.email = address;
without2faEmails++;
}
if (without2fa)
out.user = { two_factor_authentication_enabled: false };
return out;
});
});
}
/**
* `account_rulesets`, `access_apps`, `gateway_locations`,
* `zone_schema_validation_schemas`: only `.length` is read (1259-1297, 1421,
* 1744, 3872), so only the count leaves the machine.
*/
export function projectCounted(raw) {
return runProjector(counted, () => ({ count: rawItems(raw).length }));
}
const ACCESS_RULE_DETAIL_LIMIT = 5;
function accessRuleValue(value) {
if (!value)
return undefined;
// `${config.value}` is printed; a numeric value (an ASN) prints identically as a string.
return typeof value === 'number' && Number.isFinite(value) ? String(value) : value;
}
function projectAccessRules(raw, isBroad, broadOnly) {
return runProjector(list(AccessRuleData), () => {
let broadSeen = 0;
return rawItems(raw).map((rule) => {
const config = isObject(rule.configuration) ? rule.configuration : undefined;
if (!isBroad(rule, config))
return {};
broadSeen++;
if (broadSeen > ACCESS_RULE_DETAIL_LIMIT)
return broadOnly(rule, config);
// `${rule.mode || 'unknown'} ${config.target || 'target'} ${config.value || ''}` is printed.
const out = assignDefined({}, { mode: printedIfTruthy(rule.mode) });
const configuration = assignDefined({}, {
target: printedIfTruthy(config?.target),
value: accessRuleValue(config?.value),
});
if (Object.keys(configuration).length > 0)
out.configuration = configuration;
return out;
});
});
}
/**
* `account_access_rules`. createAccountAccessRulesCheck (1318-1354): broad =
* `mode === 'whitelist' || target === 'country' || target === 'asn'`; prints
* `broad.length`, `accessRules.length` and the first 5 broad rules. Later broad
* rules keep only the fields that make them broad; other rules are `{}`.
*/
export function projectAccountAccessRules(raw) {
return projectAccessRules(raw, (rule, config) => String(rule.mode || '') === 'whitelist' || config?.target === 'country' || config?.target === 'asn', (rule, config) => {
const out = {};
const target = config?.target;
if (String(rule.mode || '') === 'whitelist')
out.mode = 'whitelist';
if (target === 'country' || target === 'asn')
out.configuration = { target };
return out;
});
}
const ZONE_BROAD_TARGETS = ['ip', 'ip_range', 'country', 'asn'];
const ZONE_BROAD_VALUES = ['0.0.0.0/0', '::/0'];
/**
* `zone_access_rules`. createZoneIpAccessRulesCheck (2613-2673): broad =
* `target.toLowerCase() ∈ {ip, ip_range, country, asn} || value ∈ {0.0.0.0/0, ::/0}`.
* Same first-5 rule as the account list.
*/
export function projectZoneAccessRules(raw) {
const broadTarget = (config) => ZONE_BROAD_TARGETS.includes(String(config?.target || '').toLowerCase());
const broadValue = (config) => ZONE_BROAD_VALUES.includes(String(config?.value || ''));
return projectAccessRules(raw, (_rule, config) => broadTarget(config) || broadValue(config), (_rule, config) => {
const configuration = {};
if (broadTarget(config))
configuration.target = config?.target;
if (broadValue(config))
configuration.value = config?.value;
return { configuration };
});
}
/**
* `access_identity_providers`: `{type}`. createAccessIdentityProvidersCheck
* (1515-1540) reads `.length` and compares `type.toLowerCase()` with fixed
* literals; `type` is never printed, so non-token types become `'other'`.
*/
export function projectIdentityProviders(raw) {
return runProjector(list(IdentityProviderData), () => rawItems(raw).map((idp) => {
if (typeof idp.type !== 'string' || idp.type === '')
return {};
return { type: isToken(idp.type, 64) ? idp.type : 'other' };
}));
}
const ISO_MIN = '0000-01-01T00:00:00.000Z';
const ISO_MAX = '9999-12-31T23:59:59.999Z';
/**
* `access_service_tokens`: `expires_at`. createAccessServiceTokensCheck
* (1587-1593) only evaluates `Date.parse(expires_at || expiresAt) < now` for
* string values, so a parseable value is normalised to ISO (extended years are
* clamped to the 4-digit range, which keeps the comparison) and anything else
* is dropped (both give "not expired").
*/
export function projectServiceTokens(raw) {
return runProjector(list(ServiceTokenData), () => rawItems(raw).map((serviceToken) => {
const exp = serviceToken.expires_at || serviceToken.expiresAt;
if (typeof exp !== 'string')
return {};
const time = Date.parse(exp);
if (!Number.isFinite(time))
return {};
const isoTime = new Date(time).toISOString();
const clamped = /^\d{4}-/.test(isoTime) ? isoTime : time < 0 ? ISO_MIN : ISO_MAX;
return { expires_at: clamped };
}));
}
/**
* `gateway_rules`: `enabled:false` iff `=== false`, `filters` verbatim.
* createGatewayRulesCheck (1655-1680) counts `enabled !== false` and prints
* each filter with its count.
*/
export function projectGatewayRules(raw) {
return runProjector(list(GatewayRuleData), () => rawItems(raw).map((rule) => {
const out = {};
if (rule.enabled === false)
out.enabled = false;
if (Array.isArray(rule.filters) && rule.filters.length > 0)
out.filters = rule.filters;
return out;
}));
}
/**
* `cfd_tunnels`: `status` and `is_deleted:true` iff `deleted_at || is_deleted
* === true`. createCloudflareTunnelsCheck (1801-1814) only compares
* `status.toLowerCase()` with fixed literals, so non-token statuses become `'other'`.
*/
export function projectTunnels(raw) {
return runProjector(list(TunnelData), () => rawItems(raw).map((tunnel) => {
const out = {};
if (typeof tunnel.status === 'string' && tunnel.status !== '') {
out.status = isToken(tunnel.status, 32) ? tunnel.status : 'other';
}
if (tunnel.deleted_at || tunnel.is_deleted === true)
out.is_deleted = true;
return out;
}));
}
/**
* `zone_setting`: `value` only, verbatim. Every setting check reads
* `statusValue(read)` (= `.value`, 1053-1056) or `wafSetting.data.value`
* (2324, 3535); minify (2905-2953) and HSTS (3695-3727) print sub-keys at
* depth ≤ 2. Values are printed, so a non-token string, an array or a deeper
* object fails the entry rather than being altered.
*/
export function projectZoneSetting(raw) {
return runProjector(SettingData, () => {
// `statusValue` reads `read.data?.value`, so a null result reads like an absent value.
if (raw === null)
return {};
const value = rawObject(raw).value;
return (value === undefined ? {} : { value });
});
}
/**
* `zone_dnssec`: `status, algorithm`. createDNSSECCheck (2114-2160) compares
* and prints `status.toLowerCase()` and prints a truthy `algorithm`.
*/
export function projectDnssec(raw) {
return runProjector(DnssecData, () => {
const dnssec = rawObject(raw);
return assignDefined({}, {
status: printedIfTruthy(dnssec.status),
algorithm: printedIfTruthy(dnssec.algorithm),
});
});
}
/**
* `zone_dns_records` (all pages, API order): only `(A|AAAA) && content` records.
* findOriginExposure and detectOriginIpReuseAcrossZones (3307-3373) read
* `type`, `content` (as a Map key and through `maskIp`), `proxied` (truthiness)
* and `name`, which is printed only for non-proxied records (3292-3297); proxied
* names are only counted. `content` stays raw here and is pseudonymised by
* `createIpPseudonymizer` (via `assembleSnapshot`) once every zone is collected.
*/
export function projectDnsRecords(raw) {
return runProjector(list(DnsRecordData), () => {
const records = rawItems(raw, Number.MAX_SAFE_INTEGER)
.filter((record) => (record.type === 'A' || record.type === 'AAAA') && record.content)
.map((record) => {
const out = { type: record.type, content: record.content };
if (record.proxied)
out.proxied = true;
else if (record.name)
out.name = record.name;
return out;
});
return records.length > SNAPSHOT_LIMITS.maxListItems ? tooLarge() : records;
});
}
/**
* `zone_universal_ssl`, `zone_total_tls`, `zone_page_shield`: `enabled`.
* The checks read `enabled !== false` (3797) and `enabled === true` (2697,
* 3832); a non-boolean `enabled` gives the same outcome as an absent one.
*/
export function projectEnabledOnly(raw) {
return runProjector(enabledOnly, () => {
const value = rawObject(raw);
return typeof value.enabled === 'boolean' ? { enabled: value.enabled } : {};
});
}
function rulesetHead(ruleset) {
const out = { id: ruleset.id };
if (ruleset.phase !== undefined && ruleset.phase !== null)
out.phase = ruleset.phase;
if (typeof ruleset.name === 'string' && ruleset.name.toLowerCase().includes('ddos'))
out.name = 'ddos';
return out;
}
/**
* `zone_rulesets` heads: `id, phase`, `name:'ddos'` iff the name contains
* "ddos"; `kind` and `version` are dropped. `name` is read only by
* createDDoSCheck (2511); `phase` at 2326, 2511, 2559-2564, 3446 and 4019.
*/
export function projectRulesetHeads(raw) {
return runProjector(z.array(RulesetHeadData).max(200), () => rawItems(raw, 200).map((head) => rulesetHead(head)));
}
const isManagedPhase = (phase) => phase !== undefined && phase.includes('managed');
/**
* How many `wafAnalysis.issues` createWAFAnalysisFromRulesets (3979-4047)
* reports for one rule: one when it is disabled in a managed phase, one when
* it blocks everything (`block` with expression `true`), so 0, 1 or 2.
*/
function wafIssueCount(rule, managedPhase) {
const action = typeof rule.action === 'string' ? rule.action.toLowerCase() : undefined;
const disabledManaged = rule.enabled === false && managedPhase;
const blocksAll = action === 'block' && typeof rule.expression === 'string' && rule.expression.trim().toLowerCase() === 'true';
return (disabledManaged ? 1 : 0) + (blocksAll ? 1 : 0);
}
function isIssueRule(rule, phase) {
return wafIssueCount(rule, isManagedPhase(phase)) > 0;
}
function projectRule(rule, phase) {
const out = {};
const action = typeof rule.action === 'string' ? rule.action : undefined;
if (action !== undefined)
out.action = RULE_ACTION_RE.test(action) ? action : 'other';
if (rule.enabled === false)
out.enabled = false;
if (action?.toLowerCase() === 'block' && typeof rule.expression === 'string') {
const expression = rule.expression.toLowerCase();
if (expression.trim() === 'true')
out.expression = 'true';
else if (GEOIP_BLOCK_RE.test(expression))
out.expression = 'ip.geoip.country in';
}
if (action === 'execute' && isObject(rule.action_parameters)) {
const params = rule.action_parameters;
const executeId = String(params.id || '');
const executeName = String(params.name || '').toLowerCase();
const needles = MANAGED_NAME_NEEDLES.filter((needle) => executeName.includes(needle));
const actionParameters = {};
if (MANAGED_RULESET_IDS.includes(executeId))
actionParameters.id = executeId;
if (needles.length > 0)
actionParameters.name = needles.join(' | ');
if (Object.keys(actionParameters).length > 0)
out.action_parameters = actionParameters;
}
if (isIssueRule(rule, phase)) {
// createWAFAnalysisFromRulesets prints `ruleId: rule.id` and
// `ruleName: rule.description || rule.ref || 'Unnamed rule'` (4014-4034).
if (rule.id !== undefined)
out.id = rule.id;
if (rule.description)
out.description = rule.description;
else if (rule.ref)
out.ref = rule.ref;
}
return out;
}
/**
* `zone_ruleset` detail: the head fields plus projected rules (not yet
* encoded). Rules feed analyzeRulesets (3425-3460), createWAFAnalysisFromRulesets
* (3979-4047), hasExecutedRuleset (2412-2429) and the rate-limiting count (2559-2564):
* - `action` raw if `^[A-Za-z_]{1,32}$`, else `'other'` (never equal to a compared action);
* - `enabled:false` iff `=== false`;
* - `expression` only on block rules: `'true'` or the geoip sentinel, else omitted;
* - `action_parameters` only on `execute`: a managed `id` and the matched name needles;
* - `id`, `description` (or `ref` when `description` is empty) only on issue rules.
* More than `maxRulesPerBody` rules, or more than `maxRunsPerBody` runs, fails
* with `output_too_large`.
*/
export function projectRulesetDetail(raw) {
return runProjector(ProjectedRulesetDetailData, () => {
const ruleset = rawObject(raw);
const head = rulesetHead(ruleset);
const phase = typeof ruleset.phase === 'string' ? ruleset.phase : undefined;
const rawRules = ruleset.rules === undefined || ruleset.rules === null ? [] : ruleset.rules;
if (!Array.isArray(rawRules))
return invalid();
if (rawRules.length > SNAPSHOT_LIMITS.maxRulesPerBody)
return tooLarge();
const rules = rawRules.map((rule) => projectRule(rawObject(rule), phase));
if (encodeRuleRuns(rules).length > SNAPSHOT_LIMITS.maxRunsPerBody)
return tooLarge();
return { ...head, rules };
});
}
/**
* `zone_page_rules`: one item per rule; `actions[].id` filtered to the five
* legacy IDs (first occurrence order). createPageRulesCheck (3601-3650) reads
* `.length` and detectDeprecatedPageRuleActions.
*/
export function projectPageRules(raw) {
return runProjector(list(PageRuleData), () => rawItems(raw).map((rule) => {
const ids = [];
for (const action of Array.isArray(rule.actions) ? rule.actions : []) {
const id = isObject(action) ? String(action.id || '') : '';
if (LEGACY_PAGE_RULE_ACTIONS.includes(id) && !ids.includes(id))
ids.push(id);
}
return (ids.length > 0 ? { actions: ids.map((id) => ({ id })) } : {});
}));
}
function comparedPrimitives(source, keep) {
const out = {};
for (const [key, value] of Object.entries(source)) {
if (!isToken(key, 64))
return invalid();
const kept = keep(key, value);
if (kept !== undefined)
out[key] = kept;
}
return out;
}
function comparedPrimitive(value) {
if (typeof value === 'string')
return comparedString(value);
if (value === null || typeof value === 'boolean' || typeof value === 'number')
return value;
return undefined; // objects and arrays never equal a compared literal
}
/**
* `zone_bot_management`. createBotManagementCheck (2815-2850) prints
* `Boolean(fight_mode)` and `Boolean(enable_js)` (kept as `!!value`), tests
* `Object.values(...).some(v === 'block' || v === 'enabled')` (other strings
* become `''` or `'other'`, preserving truthiness) and prints
* `Object.keys(stale_zone_configuration).join(', ')`. That key list is kept as
* an array in exactly the `Object.keys` order the live scanner sees (integer-like
* keys first, then insertion order); the replay client turns it back into
* `{key: true}`. An object would lose the order, because the serialiser sorts
* object keys.
*/
export function projectBotManagement(raw) {
return runProjector(BotManagementData, () => comparedPrimitives(rawObject(raw), (key, value) => {
if (key === 'fight_mode' || key === 'enable_js')
return Boolean(value);
if (key === 'stale_zone_configuration' && value !== null && typeof value === 'object') {
return Object.keys(value);
}
return comparedPrimitive(value);
}));
}
/**
* `zone_schema_validation_settings`: top-level primitives (same string rule as
* bot management). createApiShieldCheck (3858-3883) compares values with
* `'block'`, `'log'` and `true`.
*/
export function projectSchemaValidationSettings(raw) {
return runProjector(SchemaValidationSettingsData, () => comparedPrimitives(rawObject(raw), (_key, value) => comparedPrimitive(value)));
}
/**
* One projector per collectable kind. Each takes the raw `cf` JSON exactly as
* printed (the bare result object, or for lists the bare arrays of every page
* concatenated in API order) and returns the snapshot data or a
* `collection_failed` detail.
*/
export const PROJECTORS = {
account: projectAccount,
zones: projectZones,
account_members: projectAccountMembers,
account_rulesets: projectCounted,
account_access_rules: projectAccountAccessRules,
access_apps: projectCounted,
access_identity_providers: projectIdentityProviders,
access_service_tokens: projectServiceTokens,
gateway_rules: projectGatewayRules,
gateway_locations: projectCounted,
cfd_tunnels: projectTunnels,
zone_setting: projectZoneSetting,
zone_dnssec: projectDnssec,
zone_dns_records: projectDnsRecords,
zone_universal_ssl: projectEnabledOnly,
zone_total_tls: projectEnabledOnly,
zone_rulesets: projectRulesetHeads,
zone_ruleset: projectRulesetDetail,
zone_access_rules: projectZoneAccessRules,
zone_page_rules: projectPageRules,
zone_bot_management: projectBotManagement,
zone_page_shield: projectEnabledOnly,
zone_schema_validation_settings: projectSchemaValidationSettings,
zone_schema_validation_schemas: projectCounted,
};
export const NOT_COLLECTED_RESULT = Object.freeze({ ok: false, reason: 'not_collected' });
export function httpErrorResult(status) {
return { ok: false, reason: 'http_error', status };
}
export function collectionFailedResult(detail) {
return { ok: false, reason: 'collection_failed', detail };
}
/** Turn a projector outcome into an entry `result`. */
export function projectionToResult(projection) {
return projection.ok ? { ok: true, data: projection.data } : collectionFailedResult(projection.detail);
}
// ============================================================================
// Rule runs and bodies
// ============================================================================
function canonicalJson(value) {
if (Array.isArray(value))
return `[${value.map(canonicalJson).join(',')}]`;
if (value !== null && typeof value === 'object') {
const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
}
return JSON.stringify(value);
}
/** Run-length encode consecutive identical projected rules into `{...rule, n}`. */
export function encodeRuleRuns(rules) {
const runs = [];
let previous;
for (const rule of rules) {
const canonical = canonicalJson(rule);
const last = runs[runs.length - 1];
if (last !== undefined && canonical === previous) {
last.n = (last.n ?? 1) + 1;
}
else {
runs.push({ ...rule });
previous = canonical;
}
}
return runs.map(({ n, ...rule }) => (n === undefined ? rule : { ...rule, n }));
}
/** Restore the exact rule order and count of a body; every rule is a fresh object. */
export function expandRuleRuns(runs) {
const rules = [];
for (const { n, ...rule } of runs) {
for (let i = 0; i < (n ?? 1); i++)
rules.push(JSON.parse(JSON.stringify(rule)));
}
return rules;
}
export function createRuleBodyEncoder() {
const refsByContent = new Map();
const bodies = {};
return {
add(rules) {
if (rules.length === 0)
return undefined;
const runs = encodeRuleRuns(rules);
const content = canonicalJson(runs);
const existing = refsByContent.get(content);
if (existing !== undefined)
return existing;
const ref = `r${refsByContent.size}`;
refsByContent.set(content, ref);
bodies[ref] = runs;
return ref;
},
bodies: () => bodies,
};
}
// ============================================================================
// IP pseudonymisation (`per_prefix_sequential/1`)
// ============================================================================
const MAX_PSEUDONYMS_PER_PREFIX = 65_535;
/**
* Per-prefix sequential pseudonyms, applied in zone list order and record API
* order. IPv6 (any `:`) keeps its first three `:`-groups and becomes
* `${prefix}:ffff:${n.toString(16)}`; IPv4 (4 dot parts) keeps `a.b` and
* becomes `a.b.${n >> 8}.${n & 255}`; `n` counts from 1 per prefix. The mapping
* is a bijection on the snapshot, keeps Map insertion order, distinct prefixes
* never collide, and scanner `maskIp` (4234-4240) output is byte-identical.
*/
export function createIpPseudonymizer() {
const memo = new Map();
const counters = new Map();
return {
pseudonymize(content) {
const known = memo.get(content);
if (known !== undefined)
return { ok: true, data: known };
if (content.length > 64 || !IP_CONTENT_RE.test(content))
return { ok: false, detail: 'invalid_output' };
let family;
let prefix;
if (content.includes(':')) {
const groups = content.split(':');
// Every IPv6 text form splits into at least three groups ("::" does);
// fewer would change maskIp's three-group prefix.
if (groups.length < 3)
return { ok: false, detail: 'invalid_output' };
family = 6;
prefix = groups.slice(0, 3).join(':');
}
else {
const parts = content.split('.');
if (parts.length !== 4)
return { ok: false, detail: 'invalid_output' };
family = 4;
prefix = `${parts[0]}.${parts[1]}`;
}
const counterKey = `${family}|${prefix}`;
const n = (counters.get(counterKey) ?? 0) + 1;
if (n > MAX_PSEUDONYMS_PER_PREFIX)
return { ok: false, detail: 'output_too_large' };
const pseudonym = family === 6 ? `${prefix}:ffff:${n.toString(16)}` : `${prefix}.${n >> 8}.${n & 255}`;
if (pseudonym.length > 64)
return { ok: false, detail: 'invalid_output' };
counters.set(counterKey, n);
memo.set(content, pseudonym);
return { ok: true, data: pseudonym };
},
};
}
/**
* Build the snapshot from collected entries. In order:
* 1. Collector rule: a zone with any `collection_failed` ruleset detail gets its
* `zone_rulesets` rewritten to `collection_failed` with that detail (first
* failure in list order) and keeps no `zone_ruleset` entries, so the scanner
* never falls back to rule-less heads (874-891).
* 2. IP pseudonymisation over `zone_dns_records` in zone list order and record
* order; a list with unrepresentable content becomes `collection_failed`.
* 3. Canonical entry order (`expectedEntryKeys`); unexpected entries go last.
* 4. Rule bodies encoded and deduplicated in that order.
* The result is not validated: parse it with `CloudflareCheckupSnapshotV1Schema`.
*/
export function assembleSnapshot(input) {
const { accountId } = input;
const zonesEntry = input.entries.find((entry) => entry.kind === 'zones');
if (!zonesEntry || zonesEntry.kind !== 'zones')
throw new Error('assembleSnapshot requires the zones entry');
const zoneIds = zonesEntry.result.data.map((zone) => zone.id);
// 1. Collector rule for failed ruleset details.
const headOrder = new Map();
for (const entry of input.entries) {
if (entry.kind === 'zone_rulesets' && entry.result.ok)
headOrder.set(entry.zoneId, entry.result.data.map((head) => head.id));
}
const failedDetailByZone = new Map();
const failures = input.entries
.filter((entry) => entry.kind === 'zone_ruleset' && !entry.result.ok && entry.result.reason === 'collection_failed')
.sort((a, b) => (headOrder.get(a.zoneId) ?? []).indexOf(a.rulesetId) - (headOrder.get(b.zoneId) ?? []).indexOf(b.rulesetId));
for (const failure of failures) {
if (headOrder.has(failure.zoneId) && !failedDetailByZone.has(failure.zoneId)) {
failedDetailByZone.set(failure.zoneId, failure.result.detail);
}
}
let entries = input.entries
.filter((entry) => !(entry.kind === 'zone_ruleset' && (failedDetailByZone.has(entry.zoneId) || !headOrder.has(entry.zoneId))))
.map((entry) => {
if (entry.kind !== 'zone_rulesets')
return entry;
const detail = failedDetailByZone.get(entry.zoneId);
return detail === undefined ? entry : { kind: 'zone_rulesets', zoneId: entry.zoneId, result: collectionFailedResult(detail) };
});
// 2. IP pseudonymisation.
const pseudonymizer = createIpPseudonymizer();
const pseudonymized = new Map();
for (const zoneId of zoneIds) {
for (const entry of entries) {
if (entry.kind !== 'zone_dns_records' || entry.zoneId !== zoneId || !entry.result.ok)
continue;
const records = entry.result.data;
const invalidContent = records.some((record) => !pseudonymFormatOk(record.content));
let failed = invalidContent ? 'invalid_output' : undefined;
const mapped = [];
for (const record of failed ? [] : records) {
const pseudonym = pseudonymizer.pseudonymize(record.content);
if (!pseudonym.ok) {
failed = pseudonym.detail;
break;
}
mapped.push({ ...record, content: pseudonym.data });
}
pseudonymized.set(entry, {
kind: 'zone_dns_records',
zoneId,
result: failed ? collectionFailedResult(failed) : { ok: true, data: mapped },
});
}
}
entries = entries.map((entry) => pseudonymized.get(entry) ?? entry);
// 3. Canonical order.
const listed = {};
for (const entry of entries) {
if (entry.kind === 'zone_rulesets' && entry.result.ok)
listed[entry.zoneId] = entry.result.data.map((head) => head.id);
}
const rank = new Map(expectedEntryKeys(accountId, zoneIds, listed).map((key, index) => [key, index]));
const ordered = entries
.map((entry, index) => ({ entry, index, rank: rank.get(snapshotEntryKey(entry, accountId)) ?? Number.MAX_SAFE_INTEGER }))
.sort((a, b) => a.rank - b.rank || a.index - b.index)
.map(({ entry }) => entry);
// 4. Rule bodies.
const encoder = createRuleBodyEncoder();
const finalEntries = ordered.map((entry) => {
if (entry.kind !== 'zone_ruleset')
return canonicalEntry(entry);
if (!entry.result.ok) {
return { kind: 'zone_ruleset', zoneId: entry.zoneId, rulesetId: entry.rulesetId, result: entry.result };
}
const { rules, ...head } = entry.result.data;
const rulesBody = encoder.add(rules);
const data = rulesBody === undefined ? { ...head } : { ...head, rulesBody };
return { kind: 'zone_ruleset', zoneId: entry.zoneId, rulesetId: entry.rulesetId, result: { ok: true, data } };
});
return {
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
projection: { version: SNAPSHOT_PROJECTION_VERSION, ipPseudonymization: IP_PSEUDONYMIZATION },
accountId,
collector: canonicalCollector(input.collector),
entries: finalEntries,
ruleBodies: encoder.bodies(),
};
}
function pseudonymFormatOk(content) {
if (content.length > 64 || !IP_CONTENT_RE.test(content))
return false;
return content.includes(':') ? content.split(':').length >= 3 : content.split('.').length === 4;
}
/** Rebuild an entry with a stable key order: kind, zoneId, settingId/rulesetId, result. */
function canonicalEntry(entry) {
const out = { kind: entry.kind };
if ('zoneId' in entry)
out.zoneId = entry.zoneId;
if ('settingId' in entry)
out.settingId = entry.settingId;
if ('rulesetId' in entry)
out.rulesetId = entry.rulesetId;
const r = entry.result;
out.result = r.ok
? { ok: true, data: r.data }
: r.reason === 'http_error'
? { ok: false, reason: 'http_error', status: r.status }
: r.reason === 'collection_failed'
? { ok: false, reason: 'collection_failed', detail: r.detail }
: { ok: false, reason: r.reason };
return out;
}
function canonicalCollector(collector) {
return {
name: collector.name,
version: collector.version,
cf: { version: collector.cf.version },
authSource: collector.authSource,
platform: collector.platform,
startedAt: collector.startedAt,
finishedAt: collector.finishedAt,
cfInvocations: collector.cfInvocations,
};
}
/**
* Stable snapshot formatting (§F7): top-level keys in a fixed order, one
* compact entry and one compact rule body per line, trailing newline. Entry
* keys keep a fixed order and data keys are sorted, so the output depends only
* on content (`serializeSnapshot(parse(text)) === text`). The output is the
* exact byte sequence the CLI writes, hashes and uploads.
*/
export function serializeSnapshot(snapshot) {
const lines = ['{'];
lines.push(` "schemaVersion": ${JSON.stringify(snapshot.schemaVersion)},`);
lines.push(` "projection": ${JSON.stringify({ version: snapshot.projection.version, ipPseudonymization: snapshot.projection.ipPseudonymization })},`);
lines.push(` "accountId": ${JSON.stringify(snapshot.accountId)},`);
lines.push(` "collector": ${JSON.stringify(canonicalCollector(snapshot.collector))},`);
const entryLines = snapshot.entries.map((entry) => ` ${entryJson(entry)}`);
lines.push(entryLines.length === 0 ? ' "entries": [],' : ` "entries": [\n${entryLines.join(',\n')}\n ],`);
const bodyLines = Object.entries(snapshot.ruleBodies).map(([ref, runs]) => ` ${JSON.stringify(ref)}: ${canonicalJson(runs)}`);
lines.push(bodyLines.length === 0 ? ' "ruleBodies": {}' : ` "ruleBodies": {\n${bodyLines.join(',\n')}\n }`);
lines.push('}');
return `${lines.join('\n')}\n`;
}
function entryJson(entry) {
const { kind, result: entryResult, ...ids } = canonicalEntry(entry);
const head = [`"kind":${JSON.stringify(kind)}`];
for (const [key, value] of Object.entries(ids))
head.push(`${JSON.stringify(key)}:${JSON.stringify(value)}`);
const r = entryResult;
const resultJson = r.ok ? `{"ok":true,"data":${canonicalJson(r.data)}}` : JSON.stringify(r);
return `{${head.join(',')},"result":${resultJson}}`;
}
// ============================================================================
// HTTP contract (responses are non-strict so the server may add fields)
// ============================================================================
export const LOCAL_ERROR_REASONS = ['session_invalid', 'session_expired', 'session_used', 'session_busy', 'session_not_ready',
'run_closed', 'browser_origin', 'malformed_json', 'digest_mismatch', 'unsupported_media_type',
'length_required', 'snapshot_too_large', 'snapshot_invalid', 'snapshot_incomplete', 'account_mismatch', 'snapshot_stale',
'collector_unsupported', 'secret_like_value', 'evaluation_failed', 'rate_limited', 'local_mode_disabled', 'storage_unavailable',
'request_invalid', 'email_invalid', 'code_invalid', 'verification_invalid', 'verification_expired'];
export const LOCAL_SESSION_STATES = ['awaiting_upload', 'processing', 'completed'];
const LocalAcceptsSchema = z.object({ schemaVersions: z.array(z.string()).min(1), maxBytes: z.number().int().positive(),
maxZones: z.number().int().positive() });
// `accountId` is null until an uploaded snapshot binds the run (the CLI chooses the account); runs created
// with an account keep it, and a completed run reports the evaluated account.
export const LocalSessionResponseSchema = z.object({ success: z.literal(true), session: z.object({ accountId: hex32.nullable(),
requester: z.string().max(260), expiresAt: iso, state: z.enum(LOCAL_SESSION_STATES),
accepts: LocalAcceptsSchema, minCliVersion: semver }) });
// `GET local-session/availability`: local checkups are on, with what a checkup would accept. While they are
// off it answers 503 `local_mode_disabled`, like every local-session route.
export const LocalAvailabilityResponseSchema = z.object({ enabled: z.literal(true), minCliVersion: semver,
accepts: LocalAcceptsSchema });
// `resultsUrl` is the run's results page; the CLI opens it only when it is on the upload origin.
export const LocalSnapshotResponseSchema = z.object({ success: z.literal(true), replayed: z.boolean(),
snapshotSha256: z.string().regex(/^[0-9a-f]{64}$/),
provenance: z.record(z.string(), z.unknown()), report: z.record(z.string(), z.unknown()),
resultsUrl: z.url().max(2048).optional() });
// Email verification in the CLI. Request bodies are strict: the server refuses fields it does not know.
export const LocalRequestBodySchema = z.object({ email: z.string().min(3).max(254), locale: z.enum(LOCAL_EMAIL_LOCALES) }).strict();
export const LocalVerifyBodySchema = z.object({ verification: z.string().regex(VERIFICATION_TOKEN_REGEX),
code: z.string().regex(EMAIL_CODE_REGEX) }).strict();
// `expiresAt` is when the emailed code (and so the token) stops working.
export const LocalRequestResponseSchema = z.object({ success: z.literal(true), verification: z.object({
token: z.string().regex(VERIFICATION_TOKEN_REGEX), requester: z.string().max(260), expiresAt: iso
}) });
export const LocalVerifyResponseSchema = z.object({ success: z.literal(true), session: z.object({
code: z.string().regex(SESSION_CODE_REGEX), requester: z.string().max(260), expiresAt: iso
}) });
export const LocalErrorResponseSchema = z.object({ success: z.literal(false), status: z.number().int(), code: z.string(),
reason: z.enum(LOCAL_ERROR_REASONS).optional(), retryAfterSec: z.number().int().nonnegative().optional(),
issues: z.array(z.object({ path: z.string().max(200), code: z.string().max(64) })).max(20).optional() });