@vigilbase/cli 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CLOUDFLARE.md +2 -0
  2. package/COMMANDS.md +43 -6
  3. package/LOCAL.md +731 -0
  4. package/README.md +58 -6
  5. package/dist/auth/device-auth.d.ts.map +1 -1
  6. package/dist/auth/device-auth.js +7 -3
  7. package/dist/auth/device-auth.js.map +1 -1
  8. package/dist/auth/index.d.ts +3 -0
  9. package/dist/auth/index.d.ts.map +1 -1
  10. package/dist/auth/index.js +2 -0
  11. package/dist/auth/index.js.map +1 -1
  12. package/dist/auth/provider.d.ts +18 -0
  13. package/dist/auth/provider.d.ts.map +1 -0
  14. package/dist/auth/provider.js +62 -0
  15. package/dist/auth/provider.js.map +1 -0
  16. package/dist/auth/refresh.d.ts +10 -0
  17. package/dist/auth/refresh.d.ts.map +1 -0
  18. package/dist/auth/refresh.js +143 -0
  19. package/dist/auth/refresh.js.map +1 -0
  20. package/dist/auth/store.d.ts +27 -2
  21. package/dist/auth/store.d.ts.map +1 -1
  22. package/dist/auth/store.js +23 -8
  23. package/dist/auth/store.js.map +1 -1
  24. package/dist/auth/token-metadata.d.ts +10 -0
  25. package/dist/auth/token-metadata.d.ts.map +1 -0
  26. package/dist/auth/token-metadata.js +22 -0
  27. package/dist/auth/token-metadata.js.map +1 -0
  28. package/dist/auth/types.d.ts +8 -1
  29. package/dist/auth/types.d.ts.map +1 -1
  30. package/dist/cli.d.ts.map +1 -1
  31. package/dist/cli.js +40 -9
  32. package/dist/cli.js.map +1 -1
  33. package/dist/commands/auth.d.ts.map +1 -1
  34. package/dist/commands/auth.js +20 -8
  35. package/dist/commands/auth.js.map +1 -1
  36. package/dist/commands/helpers.d.ts +15 -0
  37. package/dist/commands/helpers.d.ts.map +1 -1
  38. package/dist/commands/helpers.js +2 -2
  39. package/dist/commands/helpers.js.map +1 -1
  40. package/dist/commands/index.d.ts +3 -1
  41. package/dist/commands/index.d.ts.map +1 -1
  42. package/dist/commands/index.js +6 -1
  43. package/dist/commands/index.js.map +1 -1
  44. package/dist/commands/local.d.ts +30 -0
  45. package/dist/commands/local.d.ts.map +1 -0
  46. package/dist/commands/local.js +1520 -0
  47. package/dist/commands/local.js.map +1 -0
  48. package/dist/commands/public-tools.d.ts.map +1 -1
  49. package/dist/commands/public-tools.js +8 -4
  50. package/dist/commands/public-tools.js.map +1 -1
  51. package/dist/index.d.ts +2 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +1 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/lib/config.d.ts +16 -1
  56. package/dist/lib/config.d.ts.map +1 -1
  57. package/dist/lib/config.js +36 -4
  58. package/dist/lib/config.js.map +1 -1
  59. package/dist/lib/format.d.ts.map +1 -1
  60. package/dist/lib/format.js +9 -0
  61. package/dist/lib/format.js.map +1 -1
  62. package/dist/lib/help.d.ts +1 -1
  63. package/dist/lib/help.d.ts.map +1 -1
  64. package/dist/lib/help.js +9 -0
  65. package/dist/lib/help.js.map +1 -1
  66. package/dist/lib/network.d.ts.map +1 -1
  67. package/dist/lib/network.js +2 -1
  68. package/dist/lib/network.js.map +1 -1
  69. package/dist/lib/one-client.d.ts +3 -2
  70. package/dist/lib/one-client.d.ts.map +1 -1
  71. package/dist/lib/one-client.js +46 -2
  72. package/dist/lib/one-client.js.map +1 -1
  73. package/dist/lib/security-client.d.ts +1 -1
  74. package/dist/lib/tui.d.ts +309 -0
  75. package/dist/lib/tui.d.ts.map +1 -0
  76. package/dist/lib/tui.js +847 -0
  77. package/dist/lib/tui.js.map +1 -0
  78. package/dist/local/cloudflare-checkup/cf/allowlist.d.ts +95 -0
  79. package/dist/local/cloudflare-checkup/cf/allowlist.d.ts.map +1 -0
  80. package/dist/local/cloudflare-checkup/cf/allowlist.js +184 -0
  81. package/dist/local/cloudflare-checkup/cf/allowlist.js.map +1 -0
  82. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts +113 -0
  83. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts.map +1 -0
  84. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js +313 -0
  85. package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js.map +1 -0
  86. package/dist/local/cloudflare-checkup/cf/child-env.d.ts +32 -0
  87. package/dist/local/cloudflare-checkup/cf/child-env.d.ts.map +1 -0
  88. package/dist/local/cloudflare-checkup/cf/child-env.js +81 -0
  89. package/dist/local/cloudflare-checkup/cf/child-env.js.map +1 -0
  90. package/dist/local/cloudflare-checkup/cf/install.d.ts +106 -0
  91. package/dist/local/cloudflare-checkup/cf/install.d.ts.map +1 -0
  92. package/dist/local/cloudflare-checkup/cf/install.js +309 -0
  93. package/dist/local/cloudflare-checkup/cf/install.js.map +1 -0
  94. package/dist/local/cloudflare-checkup/cf/locate.d.ts +44 -0
  95. package/dist/local/cloudflare-checkup/cf/locate.d.ts.map +1 -0
  96. package/dist/local/cloudflare-checkup/cf/locate.js +228 -0
  97. package/dist/local/cloudflare-checkup/cf/locate.js.map +1 -0
  98. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +89 -0
  99. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts.map +1 -0
  100. package/dist/local/cloudflare-checkup/cf/run-cf.js +275 -0
  101. package/dist/local/cloudflare-checkup/cf/run-cf.js.map +1 -0
  102. package/dist/local/cloudflare-checkup/collector.d.ts +48 -0
  103. package/dist/local/cloudflare-checkup/collector.d.ts.map +1 -0
  104. package/dist/local/cloudflare-checkup/collector.js +296 -0
  105. package/dist/local/cloudflare-checkup/collector.js.map +1 -0
  106. package/dist/local/cloudflare-checkup/contract.d.ts +2793 -0
  107. package/dist/local/cloudflare-checkup/contract.d.ts.map +1 -0
  108. package/dist/local/cloudflare-checkup/contract.js +1614 -0
  109. package/dist/local/cloudflare-checkup/contract.js.map +1 -0
  110. package/dist/local/cloudflare-checkup/email-verification.d.ts +125 -0
  111. package/dist/local/cloudflare-checkup/email-verification.d.ts.map +1 -0
  112. package/dist/local/cloudflare-checkup/email-verification.js +212 -0
  113. package/dist/local/cloudflare-checkup/email-verification.js.map +1 -0
  114. package/dist/local/cloudflare-checkup/errors.d.ts +28 -0
  115. package/dist/local/cloudflare-checkup/errors.d.ts.map +1 -0
  116. package/dist/local/cloudflare-checkup/errors.js +99 -0
  117. package/dist/local/cloudflare-checkup/errors.js.map +1 -0
  118. package/dist/local/cloudflare-checkup/output.d.ts +24 -0
  119. package/dist/local/cloudflare-checkup/output.d.ts.map +1 -0
  120. package/dist/local/cloudflare-checkup/output.js +89 -0
  121. package/dist/local/cloudflare-checkup/output.js.map +1 -0
  122. package/dist/local/cloudflare-checkup/prompts.d.ts +41 -0
  123. package/dist/local/cloudflare-checkup/prompts.d.ts.map +1 -0
  124. package/dist/local/cloudflare-checkup/prompts.js +140 -0
  125. package/dist/local/cloudflare-checkup/prompts.js.map +1 -0
  126. package/dist/local/cloudflare-checkup/rate-budget.d.ts +42 -0
  127. package/dist/local/cloudflare-checkup/rate-budget.d.ts.map +1 -0
  128. package/dist/local/cloudflare-checkup/rate-budget.js +80 -0
  129. package/dist/local/cloudflare-checkup/rate-budget.js.map +1 -0
  130. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +53 -0
  131. package/dist/local/cloudflare-checkup/snapshot-file.d.ts.map +1 -0
  132. package/dist/local/cloudflare-checkup/snapshot-file.js +186 -0
  133. package/dist/local/cloudflare-checkup/snapshot-file.js.map +1 -0
  134. package/dist/local/cloudflare-checkup/submit-client.d.ts +98 -0
  135. package/dist/local/cloudflare-checkup/submit-client.d.ts.map +1 -0
  136. package/dist/local/cloudflare-checkup/submit-client.js +349 -0
  137. package/dist/local/cloudflare-checkup/submit-client.js.map +1 -0
  138. package/dist/local/cloudflare-checkup/terminal-ui.d.ts +171 -0
  139. package/dist/local/cloudflare-checkup/terminal-ui.d.ts.map +1 -0
  140. package/dist/local/cloudflare-checkup/terminal-ui.js +404 -0
  141. package/dist/local/cloudflare-checkup/terminal-ui.js.map +1 -0
  142. package/package.json +8 -4
@@ -0,0 +1,1614 @@
1
+ // GENERATED from packages/tools/src/cloudflare-checkup/snapshot/contract.ts — do not edit
2
+ /**
3
+ * Local Cloudflare Checkup snapshot contract (`vigilbase.cloudflare-checkup.snapshot/1`).
4
+ *
5
+ * Single source of truth for the snapshot the Vigilbase CLI collects with
6
+ * Cloudflare's `cf` CLI and vigilbase.com evaluates with the existing scanner:
7
+ * schemas, limits, request keys, projectors, the IP pseudonymiser, the rule-run
8
+ * encoder and expander, the credential tripwire, the snapshot serialiser and
9
+ * the local-session HTTP contract.
10
+ *
11
+ * Portability rules (the file is vendored byte-for-byte into packages/cli):
12
+ * - imports only `zod`, never a relative module;
13
+ * - ES2022 built-ins only, so it compiles under the tools config
14
+ * (ES2017 target, bundler resolution) and the CLI config (ES2022, NodeNext);
15
+ * - no raw line terminators inside regex literals (TS1161); use `\p{…}` escapes.
16
+ *
17
+ * Projection principle (spec §D3). A value the scanner prints is kept verbatim,
18
+ * or the whole entry becomes `collection_failed/invalid_output`. A value the
19
+ * scanner only compares against fixed literals may be mapped to a sentinel that
20
+ * cannot equal any of those literals. A value the scanner tests for truthiness
21
+ * keeps its truthiness. Projection never truncates; caps fail the entry.
22
+ * Line references below are to `packages/tools/src/cloudflare-checkup/scanner.ts`
23
+ * before the read-client refactor; the function names are the stable anchor.
24
+ *
25
+ * Any change to a field read or a projection rule is a new schema version
26
+ * (`/2`), and the server then accepts both for at least one CLI minor release.
27
+ */
28
+ import { z } from 'zod';
29
+ // ============================================================================
30
+ // Versions, HTTP contract constants
31
+ // ============================================================================
32
+ export const SNAPSHOT_SCHEMA_VERSION = 'vigilbase.cloudflare-checkup.snapshot/1';
33
+ export const SNAPSHOT_PROJECTION_VERSION = 1;
34
+ export const IP_PSEUDONYMIZATION = 'per_prefix_sequential/1';
35
+ export const SNAPSHOT_COLLECTOR_NAME = '@vigilbase/cli';
36
+ export const SESSION_CODE_REGEX = /^vbcs1_[A-Za-z0-9_-]{66}$/;
37
+ export const PRODUCTION_ORIGIN = 'https://vigilbase.com';
38
+ export const LOCAL_SESSION_PATH = '/api/v1/tools/cloudflare-checkup/local-session';
39
+ export const LOCAL_SNAPSHOT_PATH = '/api/v1/tools/cloudflare-checkup/local-session/snapshot';
40
+ /** The CLI asks vigilbase.com to email a verification code (no browser, no Turnstile). */
41
+ export const LOCAL_REQUEST_PATH = '/api/v1/tools/cloudflare-checkup/local-session/request';
42
+ /** The CLI trades the verification token and the emailed code for a session code. */
43
+ export const LOCAL_VERIFY_PATH = '/api/v1/tools/cloudflare-checkup/local-session/verify';
44
+ /** `vbcv1_` + base64url_nopad(iv[12] ‖ AES-256-GCM(runId[16] ‖ iat[4]) ‖ tag[16]): names a run awaiting its emailed code. */
45
+ export const VERIFICATION_TOKEN_REGEX = /^vbcv1_[A-Za-z0-9_-]{64}$/;
46
+ /** The six-digit code One emails for a tool run. */
47
+ export const EMAIL_CODE_REGEX = /^\d{6}$/;
48
+ /** Verification email languages; the CLI picks one from LC_ALL, LC_MESSAGES or LANG. */
49
+ export const LOCAL_EMAIL_LOCALES = ['en', 'pt-PT', 'ar'];
50
+ export const SNAPSHOT_SHA256_HEADER = 'X-Vigilbase-Snapshot-Sha256';
51
+ export const CLI_VERSION_HEADER = 'X-Vigilbase-CLI-Version';
52
+ export const CF_SUPPORTED = Object.freeze({
53
+ min: '1.0.0-beta.13',
54
+ maxExclusive: '2.0.0',
55
+ tested: Object.freeze(['1.0.0-beta.13']),
56
+ });
57
+ export const SNAPSHOT_LIMITS = Object.freeze({
58
+ maxBytes: 8_388_608,
59
+ defaultServerMaxBytes: 4_194_304,
60
+ maxZones: 100,
61
+ maxEntries: 12_000,
62
+ maxListItems: 5_000,
63
+ maxRuleBodies: 2_000,
64
+ maxRunsPerBody: 5_000,
65
+ maxRulesPerBody: 20_000,
66
+ maxRulesExpandedTotal: 400_000,
67
+ // Output caps: every expanded issue rule becomes its own `wafAnalysis.issues`
68
+ // entry with its id and name, so the result grows with the expansion. Counted
69
+ // per issue (a disabled managed block-all rule is two) and in UTF-8 bytes of
70
+ // `ruleId` + `ruleName`, summed over every `zone_ruleset` reference.
71
+ maxIssueRulesExpandedTotal: 20_000,
72
+ maxIssueTextExpandedBytes: 2_097_152,
73
+ maxCollectionHours: 6,
74
+ maxAgeHours: 24,
75
+ maxFutureSkewSeconds: 300,
76
+ // cf prints cursor-only lists without the cursor; a list this long may be
77
+ // truncated. Lowered to the server's per_page maximum observed in launch gate 1.
78
+ rulesetListTruncationAt: 100,
79
+ });
80
+ // The 19 zone settings the scanner reads (scanZone 664-732; phase functions).
81
+ export const ZONE_SETTING_IDS = [
82
+ 'ssl',
83
+ 'always_use_https',
84
+ 'min_tls_version',
85
+ 'tls_1_3',
86
+ 'security_level',
87
+ 'email_obfuscation',
88
+ 'hotlink_protection',
89
+ 'browser_check',
90
+ 'origin_pulls',
91
+ 'challenge_ttl',
92
+ 'browser_cache_ttl',
93
+ 'minify',
94
+ 'early_hints',
95
+ 'rocket_loader',
96
+ 'waf',
97
+ 'automatic_https_rewrites',
98
+ 'security_header',
99
+ 'opportunistic_encryption',
100
+ 'always_online',
101
+ ];
102
+ // Public Cloudflare managed ruleset IDs, segmented like scanner.ts:56-62 to
103
+ // avoid credential heuristics.
104
+ const seg = (...parts) => parts.join('');
105
+ export const MANAGED_RULESET_IDS = Object.freeze([
106
+ seg('efb7b8c949ac4650', 'a09736fc376e9aee'),
107
+ seg('4814384a9e5d4991', 'b9815dcfc25d2f1f'),
108
+ seg('c2e184081120413c', '86c3ab7e14069605'),
109
+ seg('e22d83c647c64a3e', 'ae91b71b499d988e'),
110
+ ]);
111
+ // Lower-cased managed ruleset names `hasExecutedRuleset` searches for (2412-2429).
112
+ export const MANAGED_NAME_NEEDLES = [
113
+ 'cloudflare managed ruleset',
114
+ 'owasp core ruleset',
115
+ 'exposed credentials check',
116
+ 'sensitive data detection',
117
+ ];
118
+ // `LEGACY_PAGE_RULE_ACTIONS` (scanner.ts:64-70).
119
+ export const LEGACY_PAGE_RULE_ACTIONS = [
120
+ 'disable_security',
121
+ 'disable_performance',
122
+ 'waf',
123
+ 'security_level',
124
+ 'browser_check',
125
+ ];
126
+ export const ACCOUNT_KINDS = [
127
+ 'account',
128
+ 'zones',
129
+ 'account_members',
130
+ 'account_rulesets',
131
+ 'account_access_rules',
132
+ 'access_apps',
133
+ 'access_identity_providers',
134
+ 'access_service_tokens',
135
+ 'gateway_rules',
136
+ 'gateway_locations',
137
+ 'cfd_tunnels',
138
+ ];
139
+ export const ZONE_SINGLE_KINDS = [
140
+ 'zone_dnssec',
141
+ 'zone_dns_records',
142
+ 'zone_universal_ssl',
143
+ 'zone_total_tls',
144
+ 'zone_rulesets',
145
+ 'zone_access_rules',
146
+ 'zone_page_rules',
147
+ 'zone_bot_management',
148
+ 'zone_page_shield',
149
+ 'zone_schema_validation_settings',
150
+ 'zone_schema_validation_schemas',
151
+ ];
152
+ // cf 1.0.0-beta.13 has no command for these reads; they are always `not_collected`.
153
+ export const NOT_COLLECTABLE_ZONE_KINDS = [
154
+ 'zone_legacy_firewall_rules',
155
+ 'zone_legacy_filters',
156
+ 'zone_legacy_waf_packages',
157
+ 'zone_legacy_rate_limits',
158
+ 'zone_waf_analytics',
159
+ ];
160
+ export const SNAPSHOT_KINDS = [
161
+ ...ACCOUNT_KINDS,
162
+ 'zone_setting',
163
+ ...ZONE_SINGLE_KINDS,
164
+ 'zone_ruleset',
165
+ ...NOT_COLLECTABLE_ZONE_KINDS,
166
+ ];
167
+ export const COLLECTION_FAILURE_DETAILS = [
168
+ 'cf_error',
169
+ 'invalid_output',
170
+ 'timeout',
171
+ 'output_too_large',
172
+ 'possibly_truncated',
173
+ 'page_limit',
174
+ 'command_unavailable',
175
+ ];
176
+ // Scope names verified in cf beta.13; their sufficiency is launch gate 1.
177
+ export const RECOMMENDED_CF_SCOPES = [
178
+ 'account:read',
179
+ 'account-settings.read',
180
+ 'memberships.read',
181
+ 'user:read',
182
+ 'user-details.read',
183
+ 'zone:read',
184
+ 'zone.read',
185
+ 'zone-settings.read',
186
+ 'dns.read',
187
+ 'dns_records:read',
188
+ 'ssl-and-certificates.read',
189
+ 'zone-waf.read',
190
+ 'account-waf.read',
191
+ 'account-rulesets.read',
192
+ 'http-ddos-managed-ruleset.read',
193
+ 'firewall-services.read',
194
+ 'account-firewall-access-rules.read',
195
+ 'page-rules.read',
196
+ 'bot-management.read',
197
+ 'page-shield.read',
198
+ 'domain-page-shield.read',
199
+ 'api-gateway.read',
200
+ 'access.read',
201
+ 'access:read',
202
+ 'access-app.read',
203
+ 'access-idp.read',
204
+ 'access-service-token.read',
205
+ 'teams.read',
206
+ 'teams:read',
207
+ 'argotunnel.read',
208
+ 'teams-connector-cloudflared.read',
209
+ ];
210
+ // ============================================================================
211
+ // Versions
212
+ // ============================================================================
213
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.]+))?$/;
214
+ /**
215
+ * SemVer 2 precedence. Returns a negative number, zero or a positive number,
216
+ * or `NaN` when either side is not `MAJOR.MINOR.PATCH[-PRERELEASE]`.
217
+ */
218
+ export function compareSemver(a, b) {
219
+ const left = SEMVER_RE.exec(a);
220
+ const right = SEMVER_RE.exec(b);
221
+ if (!left || !right)
222
+ return Number.NaN;
223
+ for (let i = 1; i <= 3; i++) {
224
+ const diff = Number(left[i]) - Number(right[i]);
225
+ if (diff !== 0)
226
+ return diff;
227
+ }
228
+ const leftPre = left[4];
229
+ const rightPre = right[4];
230
+ if (leftPre === undefined || rightPre === undefined) {
231
+ if (leftPre === rightPre)
232
+ return 0;
233
+ return leftPre === undefined ? 1 : -1;
234
+ }
235
+ const leftIds = leftPre.split('.');
236
+ const rightIds = rightPre.split('.');
237
+ for (let i = 0; i < Math.min(leftIds.length, rightIds.length); i++) {
238
+ const l = leftIds[i];
239
+ const r = rightIds[i];
240
+ if (l === r)
241
+ continue;
242
+ const lNum = /^\d+$/.test(l);
243
+ const rNum = /^\d+$/.test(r);
244
+ if (lNum && rNum)
245
+ return Number(l) - Number(r);
246
+ if (lNum !== rNum)
247
+ return lNum ? -1 : 1;
248
+ return l < r ? -1 : 1;
249
+ }
250
+ return leftIds.length - rightIds.length;
251
+ }
252
+ /**
253
+ * True when `version` lies in `[CF_SUPPORTED.min, CF_SUPPORTED.maxExclusive)`.
254
+ * The upper bound compares release cores only, so a `2.0.0-beta.1` preview of
255
+ * the next major is unsupported too.
256
+ */
257
+ export function isSupportedCfVersion(version) {
258
+ const core = SEMVER_RE.exec(version);
259
+ if (!core)
260
+ return false;
261
+ const atLeastMin = compareSemver(version, CF_SUPPORTED.min) >= 0;
262
+ const belowMax = compareSemver(`${core[1]}.${core[2]}.${core[3]}`, CF_SUPPORTED.maxExclusive) < 0;
263
+ return atLeastMin && belowMax;
264
+ }
265
+ // ============================================================================
266
+ // Primitive schemas
267
+ // ============================================================================
268
+ // Control (Cc), format/bidi (Cf), line separator (Zl = LS), paragraph separator (Zp = PS)
269
+ // and lone UTF-16 surrogates (Cs; a `u` regex sees a valid pair as one astral code point).
270
+ // Postgres jsonb rejects a lone surrogate escape, so one would strand the run in One.
271
+ // Property escapes only: a raw LS/PS inside a regex literal is a SyntaxError (TS1161).
272
+ // Not `String.prototype.isWellFormed()`: it is ES2024 and the CLI copy compiles against ES2022.
273
+ const CTRL = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/u;
274
+ const TOKEN_RE = /^[A-Za-z0-9_.:-]+$/;
275
+ export const HEX32_REGEX = /^[0-9a-f]{32}$/;
276
+ const IP_CONTENT_RE = /^[0-9A-Fa-f:.]+$/;
277
+ const ACCESS_VALUE_RE = /^[A-Za-z0-9.:/_*-]+$/;
278
+ const RULE_ACTION_RE = /^[A-Za-z_]{1,32}$/;
279
+ const GEOIP_BLOCK_RE = /ip\.geoip\.country\s+(eq|in)/;
280
+ const text = (max) => z.string().max(max).refine((v) => !CTRL.test(v), 'control_or_format_character');
281
+ const token = (max = 64) => z.string().min(1).max(max).regex(TOKEN_RE);
282
+ const hex32 = z.string().regex(HEX32_REGEX);
283
+ const hostname = z.string().min(1).max(253).regex(/^[A-Za-z0-9*_.-]+$/);
284
+ const semver = z.string().max(40).regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/);
285
+ const iso = z.iso.datetime({ offset: true });
286
+ const scalar = z.union([token(64), z.literal(''), z.number().finite(), z.boolean(), z.null()]);
287
+ 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
288
+ const accessValue = z.string().min(1).max(128).regex(ACCESS_VALUE_RE);
289
+ const bodyRef = z.string().regex(/^r[0-9]{1,5}$/);
290
+ const httpError = z.object({ ok: z.literal(false), reason: z.literal('http_error'), status: z.number().int().min(400).max(599) }).strict();
291
+ const notCollected = z.object({ ok: z.literal(false), reason: z.literal('not_collected') }).strict();
292
+ const collectionFailed = z.object({ ok: z.literal(false), reason: z.literal('collection_failed'), detail: z.enum(COLLECTION_FAILURE_DETAILS) }).strict();
293
+ const ok = (data) => z.object({ ok: z.literal(true), data }).strict();
294
+ const result = (data) => z.union([ok(data), httpError, collectionFailed]);
295
+ const list = (item, max = SNAPSHOT_LIMITS.maxListItems) => z.array(item).max(max);
296
+ const counted = z.object({ count: z.number().int().min(0).max(SNAPSHOT_LIMITS.maxListItems) }).strict();
297
+ const enabledOnly = z.object({ enabled: z.boolean().optional() }).strict();
298
+ // ============================================================================
299
+ // Entry data schemas
300
+ // ============================================================================
301
+ export const AccountData = z.object({ id: hex32, name: text(256), type: token(32).optional(),
302
+ settings: z.object({ enforce_twofactor: z.boolean().optional() }).strict().optional() }).strict();
303
+ export const ZoneData = z.object({ id: hex32, name: hostname, status: token(32).optional(), paused: z.boolean().optional(),
304
+ type: token(32).optional(), development_mode: z.number().int().optional(),
305
+ plan: z.object({ name: text(64) }).strict().optional(), account: z.object({ id: hex32, name: text(256) }).strict() }).strict();
306
+ export const MemberData = z.object({ status: z.literal('pending').optional(), email: email.optional(),
307
+ user: z.object({ two_factor_authentication_enabled: z.literal(false) }).strict().optional() }).strict();
308
+ export const AccessRuleData = z.object({ mode: token(32).optional(),
309
+ configuration: z.object({ target: token(32).optional(), value: accessValue.optional() }).strict().optional() }).strict();
310
+ export const IdentityProviderData = z.object({ type: token(64).optional() }).strict();
311
+ export const ServiceTokenData = z.object({ expires_at: iso.optional() }).strict();
312
+ export const GatewayRuleData = z.object({ enabled: z.literal(false).optional(), filters: z.array(token(32)).max(16).optional() }).strict();
313
+ export const TunnelData = z.object({ status: token(32).optional(), is_deleted: z.literal(true).optional() }).strict();
314
+ export const SettingData = z.object({ value: z.union([scalar,
315
+ z.record(token(64), z.union([scalar, z.record(token(64), scalar)]))]).optional() }).strict();
316
+ export const DnssecData = z.object({ status: token(32).optional(), algorithm: z.union([token(16), z.number().int()]).optional() }).strict();
317
+ export const DnsRecordData = z.object({ type: z.enum(['A', 'AAAA']), name: hostname.optional(),
318
+ content: z.string().min(1).max(64).regex(IP_CONTENT_RE), proxied: z.literal(true).optional() }).strict();
319
+ export const RulesetHeadData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional() }).strict();
320
+ export const RulesetDetailData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional(),
321
+ rulesBody: bodyRef.optional() }).strict();
322
+ export const PageRuleData = z.object({ actions: z.array(z.object({ id: z.enum(LEGACY_PAGE_RULE_ACTIONS) }).strict()).max(32).optional() }).strict();
323
+ // `stale_zone_configuration` is its key list in API order: the scanner prints
324
+ // `Object.keys(...)`, and the serialiser sorts object keys, so a `{key: true}`
325
+ // object would reach the server in alphabetical order.
326
+ const staleKeyList = z.array(token(64)).max(64).refine((keys) => new Set(keys).size === keys.length, 'duplicate_stale_key');
327
+ export const BotManagementData = z.record(token(64), z.union([scalar, staleKeyList]))
328
+ .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')
329
+ .refine((o) => ['fight_mode', 'enable_js'].every((k) => !(k in o) || typeof o[k] === 'boolean'), 'truthiness_keys_are_booleans');
330
+ export const SchemaValidationSettingsData = z.record(token(64), scalar).refine((o) => Object.keys(o).length <= 64, 'too_many_keys');
331
+ export const CountedData = counted;
332
+ export const EnabledOnlyData = enabledOnly;
333
+ const needleJoin = z.string().max(200).refine((v) => {
334
+ const parts = v.split(' | ');
335
+ return parts.every((x) => MANAGED_NAME_NEEDLES.includes(x)) && new Set(parts).size === parts.length;
336
+ });
337
+ const ruleShape = {
338
+ action: z.string().regex(RULE_ACTION_RE).optional(), // non-conforming raw actions become 'other'
339
+ enabled: z.literal(false).optional(),
340
+ expression: z.enum(['true', 'ip.geoip.country in']).optional(),
341
+ action_parameters: z.object({ id: z.string().refine((v) => MANAGED_RULESET_IDS.includes(v)).optional(),
342
+ name: needleJoin.optional() }).strict().optional(),
343
+ id: token(64).optional(),
344
+ ref: text(128).optional(),
345
+ description: text(500).optional(),
346
+ };
347
+ export const ProjectedRuleData = z.object(ruleShape).strict();
348
+ export const RuleRunData = z.object({
349
+ n: z.number().int().min(2).max(SNAPSHOT_LIMITS.maxRulesPerBody).optional(),
350
+ ...ruleShape,
351
+ }).strict();
352
+ // Collector-side ruleset detail: rules are projected but not yet encoded into
353
+ // `ruleBodies` (`assembleSnapshot` does that once every zone is collected).
354
+ export const ProjectedRulesetDetailData = z.object({ id: hex32, phase: token(64).optional(), name: z.literal('ddos').optional(),
355
+ rules: z.array(ProjectedRuleData).max(SNAPSHOT_LIMITS.maxRulesPerBody) }).strict();
356
+ // ============================================================================
357
+ // Entries and the snapshot
358
+ // ============================================================================
359
+ const A = (kind, r) => z.object({ kind: z.literal(kind), result: r }).strict();
360
+ const Z = (kind, r) => z.object({ kind: z.literal(kind), zoneId: hex32, result: r }).strict();
361
+ export const SnapshotEntrySchema = z.discriminatedUnion('kind', [
362
+ A('account', z.union([ok(AccountData), httpError])), // the scanner tolerates 400/401/403/404 (runInitPhase 4369-4378); other failures abort collection
363
+ A('zones', ok(list(ZoneData, SNAPSHOT_LIMITS.maxZones))),
364
+ A('account_members', result(list(MemberData))),
365
+ A('account_rulesets', result(counted)),
366
+ A('account_access_rules', result(list(AccessRuleData))),
367
+ A('access_apps', result(counted)),
368
+ A('access_identity_providers', result(list(IdentityProviderData))),
369
+ A('access_service_tokens', result(list(ServiceTokenData))),
370
+ A('gateway_rules', result(list(GatewayRuleData))),
371
+ A('gateway_locations', result(counted)),
372
+ A('cfd_tunnels', result(list(TunnelData))),
373
+ z.object({ kind: z.literal('zone_setting'), zoneId: hex32, settingId: z.enum(ZONE_SETTING_IDS), result: result(SettingData) }).strict(),
374
+ Z('zone_dnssec', result(DnssecData)),
375
+ Z('zone_dns_records', result(list(DnsRecordData))),
376
+ Z('zone_universal_ssl', result(enabledOnly)),
377
+ Z('zone_total_tls', result(enabledOnly)),
378
+ Z('zone_rulesets', result(z.array(RulesetHeadData).max(200))),
379
+ // A failed detail is never written: the collector fails the zone's zone_rulesets instead (§D1, §F5).
380
+ z.object({ kind: z.literal('zone_ruleset'), zoneId: hex32, rulesetId: hex32,
381
+ result: z.union([ok(RulesetDetailData), httpError]) }).strict(),
382
+ Z('zone_access_rules', result(list(AccessRuleData))),
383
+ Z('zone_page_rules', result(list(PageRuleData))),
384
+ Z('zone_bot_management', result(BotManagementData)),
385
+ Z('zone_page_shield', result(enabledOnly)),
386
+ Z('zone_schema_validation_settings', result(SchemaValidationSettingsData)),
387
+ Z('zone_schema_validation_schemas', result(counted)),
388
+ Z('zone_legacy_firewall_rules', notCollected),
389
+ Z('zone_legacy_filters', notCollected),
390
+ Z('zone_legacy_waf_packages', notCollected),
391
+ Z('zone_legacy_rate_limits', notCollected),
392
+ Z('zone_waf_analytics', notCollected),
393
+ ]);
394
+ export const SnapshotCollectorSchema = z.object({
395
+ name: z.literal(SNAPSHOT_COLLECTOR_NAME), version: semver, cf: z.object({ version: semver }).strict(),
396
+ authSource: z.enum(['cf_profile', 'cf_env_token', 'cf_default_profile']), // auth KIND only, never a credential
397
+ platform: z.enum(['darwin', 'linux', 'win32']),
398
+ startedAt: iso, finishedAt: iso, cfInvocations: z.number().int().min(0).max(50_000),
399
+ }).strict();
400
+ const SnapshotObjectSchema = z.object({
401
+ schemaVersion: z.literal(SNAPSHOT_SCHEMA_VERSION),
402
+ projection: z.object({ version: z.literal(SNAPSHOT_PROJECTION_VERSION), ipPseudonymization: z.literal(IP_PSEUDONYMIZATION) }).strict(),
403
+ accountId: hex32,
404
+ collector: SnapshotCollectorSchema,
405
+ entries: z.array(SnapshotEntrySchema).min(2).max(SNAPSHOT_LIMITS.maxEntries),
406
+ ruleBodies: z.record(bodyRef, z.array(RuleRunData).min(1).max(SNAPSHOT_LIMITS.maxRunsPerBody)),
407
+ }).strict();
408
+ export const CloudflareCheckupSnapshotV1Schema = SnapshotObjectSchema.superRefine(validateSnapshotSemantics);
409
+ const MAX_REPEATED_ISSUES = 20;
410
+ const ACCOUNT_TOLERATED_STATUSES = [400, 401, 403, 404];
411
+ /**
412
+ * Cross-entry rules the per-entry schema cannot express. Runs as the
413
+ * snapshot schema's `superRefine`; every issue carries `params.reason`, which
414
+ * `snapshotRejectReason` maps to the HTTP reason. The time window relative to
415
+ * the server clock is checked in com, not here.
416
+ */
417
+ export function validateSnapshotSemantics(snapshot, ctx) {
418
+ const counts = new Map();
419
+ const issue = (message, path, reason = 'snapshot_invalid') => {
420
+ const seen = counts.get(message) ?? 0;
421
+ counts.set(message, seen + 1);
422
+ if (seen < MAX_REPEATED_ISSUES)
423
+ ctx.addIssue({ code: 'custom', message, path, params: { reason } });
424
+ };
425
+ const { accountId, entries, ruleBodies } = snapshot;
426
+ // 1. Exactly one account and one zones entry; the account failure is a tolerated status.
427
+ const accountEntries = entries.filter((e) => e.kind === 'account');
428
+ const zonesEntries = entries.filter((e) => e.kind === 'zones');
429
+ if (accountEntries.length !== 1)
430
+ issue('account_entry_count', ['entries'], 'snapshot_incomplete');
431
+ if (zonesEntries.length !== 1)
432
+ issue('zones_entry_count', ['entries'], 'snapshot_incomplete');
433
+ entries.forEach((entry, i) => {
434
+ if (entry.kind !== 'account')
435
+ return;
436
+ if (entry.result.ok) {
437
+ if (entry.result.data.id !== accountId)
438
+ issue('account_id_mismatch', ['entries', i, 'result', 'data', 'id']);
439
+ }
440
+ else if (!ACCOUNT_TOLERATED_STATUSES.includes(entry.result.status)) {
441
+ issue('account_failure_status', ['entries', i, 'result', 'status']);
442
+ }
443
+ });
444
+ // 2. Zones: unique, owned by this account.
445
+ const zonesEntry = zonesEntries[0];
446
+ const zones = zonesEntry?.kind === 'zones' ? zonesEntry.result.data : [];
447
+ const zoneIds = zones.map((zone) => zone.id);
448
+ const zoneIdSet = new Set(zoneIds);
449
+ if (zoneIdSet.size !== zoneIds.length)
450
+ issue('zone_duplicate', ['entries']);
451
+ if (zoneIds.length > SNAPSHOT_LIMITS.maxZones)
452
+ issue('too_many_zones', ['entries']);
453
+ zones.forEach((zone, z) => {
454
+ if (zone.account.id !== accountId) {
455
+ const index = entries.indexOf(zonesEntry);
456
+ issue('zone_account_mismatch', ['entries', index, 'result', 'data', z, 'account', 'id']);
457
+ }
458
+ });
459
+ // 3. Exact key set.
460
+ const listedRulesets = {};
461
+ const failedRulesetLists = new Set();
462
+ const phaseByRulesetRef = new Map();
463
+ for (const entry of entries) {
464
+ if (entry.kind !== 'zone_rulesets' || !zoneIdSet.has(entry.zoneId))
465
+ continue;
466
+ if (entry.result.ok)
467
+ listedRulesets[entry.zoneId] = entry.result.data.map((head) => head.id);
468
+ else
469
+ failedRulesetLists.add(entry.zoneId);
470
+ }
471
+ const expected = new Set(expectedEntryKeys(accountId, zoneIds, listedRulesets));
472
+ const seen = new Set();
473
+ entries.forEach((entry, i) => {
474
+ if ('zoneId' in entry && !zoneIdSet.has(entry.zoneId)) {
475
+ issue('foreign_zone', ['entries', i, 'zoneId'], 'snapshot_incomplete');
476
+ return;
477
+ }
478
+ if (entry.kind === 'zone_ruleset') {
479
+ if (failedRulesetLists.has(entry.zoneId)) {
480
+ issue('ruleset_under_failed_list', ['entries', i], 'snapshot_incomplete');
481
+ return;
482
+ }
483
+ if (!(listedRulesets[entry.zoneId] ?? []).includes(entry.rulesetId)) {
484
+ issue('unlisted_ruleset', ['entries', i, 'rulesetId'], 'snapshot_incomplete');
485
+ return;
486
+ }
487
+ if (entry.result.ok) {
488
+ if (entry.result.data.id !== entry.rulesetId)
489
+ issue('ruleset_id_mismatch', ['entries', i, 'result', 'data', 'id']);
490
+ const ref = entry.result.data.rulesBody;
491
+ if (ref !== undefined) {
492
+ const phases = phaseByRulesetRef.get(ref) ?? [];
493
+ phases.push(entry.result.data.phase);
494
+ phaseByRulesetRef.set(ref, phases);
495
+ }
496
+ }
497
+ }
498
+ const key = snapshotEntryKey(entry, accountId);
499
+ if (seen.has(key))
500
+ issue('duplicate_entry', ['entries', i], 'snapshot_incomplete');
501
+ else if (!expected.has(key))
502
+ issue('unexpected_entry', ['entries', i], 'snapshot_incomplete');
503
+ seen.add(key);
504
+ });
505
+ for (const key of expected) {
506
+ if (!seen.has(key))
507
+ issue('missing_entry', ['entries'], 'snapshot_incomplete');
508
+ }
509
+ // 4. Rule bodies: bounded, all resolved, none unreferenced, global expansion cap.
510
+ const bodyRefs = Object.keys(ruleBodies);
511
+ if (bodyRefs.length > SNAPSHOT_LIMITS.maxRuleBodies)
512
+ issue('too_many_rule_bodies', ['ruleBodies']);
513
+ const expandedByRef = new Map();
514
+ for (const ref of bodyRefs) {
515
+ const runs = ruleBodies[ref];
516
+ const expanded = runs.reduce((sum, run) => sum + (run.n ?? 1), 0);
517
+ expandedByRef.set(ref, expanded);
518
+ if (expanded > SNAPSHOT_LIMITS.maxRulesPerBody)
519
+ issue('rule_body_too_large', ['ruleBodies', ref]);
520
+ runs.forEach((run, j) => {
521
+ const path = ['ruleBodies', ref, j];
522
+ const action = run.action?.toLowerCase();
523
+ if (run.expression !== undefined && action !== 'block')
524
+ issue('expression_on_non_block_rule', [...path, 'expression']);
525
+ if (run.action_parameters !== undefined && run.action !== 'execute') {
526
+ issue('action_parameters_on_non_execute_rule', [...path, 'action_parameters']);
527
+ }
528
+ if (run.ref !== undefined && run.description)
529
+ issue('ref_with_description', [...path, 'ref']);
530
+ const carriesDetail = run.id !== undefined || run.description !== undefined || run.ref !== undefined;
531
+ if (carriesDetail && (phaseByRulesetRef.get(ref) ?? []).some((phase) => !isIssueRule(run, phase))) {
532
+ issue('detail_on_non_issue_rule', path);
533
+ }
534
+ });
535
+ }
536
+ // The evaluation output grows with the expansion too: every expanded issue
537
+ // rule becomes its own `wafAnalysis.issues` entry carrying its id and name.
538
+ let expandedTotal = 0;
539
+ let issuesTotal = 0;
540
+ let issueTextBytes = 0;
541
+ const issueLoadByRefPhase = new Map();
542
+ entries.forEach((entry, i) => {
543
+ if (entry.kind !== 'zone_ruleset' || !entry.result.ok)
544
+ return;
545
+ const ref = entry.result.data.rulesBody;
546
+ if (ref === undefined)
547
+ return;
548
+ if (!Object.prototype.hasOwnProperty.call(ruleBodies, ref)) {
549
+ issue('rule_body_unresolved', ['entries', i, 'result', 'data', 'rulesBody']);
550
+ return;
551
+ }
552
+ expandedTotal += expandedByRef.get(ref) ?? 0;
553
+ // A phase matters only through `includes('managed')`, so each body is scanned at most twice.
554
+ const managedPhase = isManagedPhase(entry.result.data.phase);
555
+ const loadKey = `${ref}|${managedPhase ? 'managed' : 'other'}`;
556
+ let load = issueLoadByRefPhase.get(loadKey);
557
+ if (load === undefined) {
558
+ load = wafIssueLoad(ruleBodies[ref], managedPhase);
559
+ issueLoadByRefPhase.set(loadKey, load);
560
+ }
561
+ issuesTotal += load.issues;
562
+ issueTextBytes += load.textBytes;
563
+ });
564
+ for (const ref of bodyRefs) {
565
+ if (!phaseByRulesetRef.has(ref))
566
+ issue('rule_body_unreferenced', ['ruleBodies', ref]);
567
+ }
568
+ if (expandedTotal > SNAPSHOT_LIMITS.maxRulesExpandedTotal)
569
+ issue('expanded_rules_cap', ['ruleBodies']);
570
+ if (issuesTotal > SNAPSHOT_LIMITS.maxIssueRulesExpandedTotal)
571
+ issue('expanded_issue_rules_cap', ['ruleBodies']);
572
+ if (issueTextBytes > SNAPSHOT_LIMITS.maxIssueTextExpandedBytes)
573
+ issue('expanded_issue_text_cap', ['ruleBodies']);
574
+ // 5. Timestamps: startedAt <= finishedAt <= startedAt + 6 h.
575
+ const started = Date.parse(snapshot.collector.startedAt);
576
+ const finished = Date.parse(snapshot.collector.finishedAt);
577
+ if (!(started <= finished) || finished - started > SNAPSHOT_LIMITS.maxCollectionHours * 3_600_000) {
578
+ issue('collection_window', ['collector', 'finishedAt']);
579
+ }
580
+ // 6. Credential tripwire over every string (and record key) in the snapshot.
581
+ walkStrings(snapshot, [], (value, path) => {
582
+ if (containsSecretLikeValue(value))
583
+ issue('secret_like_value', path, 'secret_like_value');
584
+ });
585
+ }
586
+ /**
587
+ * The `wafAnalysis.issues` one reference to a rule body produces under a
588
+ * ruleset phase, and the UTF-8 bytes of the rule text those issues print
589
+ * (`ruleId` and `ruleName = description || ref`).
590
+ */
591
+ function wafIssueLoad(runs, managedPhase) {
592
+ let issues = 0;
593
+ let textBytes = 0;
594
+ for (const run of runs) {
595
+ const perRule = wafIssueCount(run, managedPhase);
596
+ if (perRule === 0)
597
+ continue;
598
+ const count = (run.n ?? 1) * perRule;
599
+ issues += count;
600
+ textBytes += count * (utf8Length(run.id ?? '') + utf8Length(run.description || run.ref || ''));
601
+ }
602
+ return { issues, textBytes };
603
+ }
604
+ /** UTF-8 length of a string (a lone surrogate counts as the 3-byte replacement character). */
605
+ function utf8Length(value) {
606
+ let bytes = 0;
607
+ for (let i = 0; i < value.length; i++) {
608
+ const unit = value.charCodeAt(i);
609
+ if (unit < 0x80)
610
+ bytes += 1;
611
+ else if (unit < 0x800)
612
+ bytes += 2;
613
+ else if (unit >= 0xd800 && unit <= 0xdbff && i + 1 < value.length && (value.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
614
+ bytes += 4;
615
+ i++;
616
+ }
617
+ else
618
+ bytes += 3;
619
+ }
620
+ return bytes;
621
+ }
622
+ const SESSION_CODE_LIKE_RE = /vbc[sv]1_[A-Za-z0-9_-]{20,}/;
623
+ const JWT_LIKE_RE = /eyJ[\w-]{10,}\.[\w-]{10,}\./;
624
+ const PEM_LIKE_RE = /-----BEGIN/;
625
+ const BEARER_LIKE_RE = /Bearer\s+\S{20,}/;
626
+ const TOKEN_CHARSET_RUN_RE = /[A-Za-z0-9_-]{40,}/g;
627
+ /**
628
+ * Credential tripwire (§D1.6): a Vigilbase session code or verification token, a 40-character
629
+ * `[A-Za-z0-9_-]` window with upper case, lower case and a digit (the
630
+ * Cloudflare API token shape), a JWT, a PEM header or a bearer header.
631
+ */
632
+ export function containsSecretLikeValue(value) {
633
+ if (SESSION_CODE_LIKE_RE.test(value) || JWT_LIKE_RE.test(value) || PEM_LIKE_RE.test(value) || BEARER_LIKE_RE.test(value)) {
634
+ return true;
635
+ }
636
+ for (const match of value.match(TOKEN_CHARSET_RUN_RE) ?? []) {
637
+ if (hasMixedClassWindow(match, 40))
638
+ return true;
639
+ }
640
+ return false;
641
+ }
642
+ function hasMixedClassWindow(run, width) {
643
+ let upper = 0;
644
+ let lower = 0;
645
+ let digit = 0;
646
+ const classOf = (c) => (c >= 'A' && c <= 'Z' ? 1 : c >= 'a' && c <= 'z' ? 2 : c >= '0' && c <= '9' ? 3 : 0);
647
+ const bump = (c, delta) => {
648
+ const k = classOf(c);
649
+ if (k === 1)
650
+ upper += delta;
651
+ else if (k === 2)
652
+ lower += delta;
653
+ else if (k === 3)
654
+ digit += delta;
655
+ };
656
+ for (let i = 0; i < run.length; i++) {
657
+ bump(run[i], 1);
658
+ if (i >= width)
659
+ bump(run[i - width], -1);
660
+ if (i >= width - 1 && upper > 0 && lower > 0 && digit > 0)
661
+ return true;
662
+ }
663
+ return false;
664
+ }
665
+ function walkStrings(value, path, visit) {
666
+ if (typeof value === 'string') {
667
+ visit(value, path);
668
+ }
669
+ else if (Array.isArray(value)) {
670
+ value.forEach((item, i) => walkStrings(item, [...path, i], visit));
671
+ }
672
+ else if (value !== null && typeof value === 'object') {
673
+ for (const [key, item] of Object.entries(value)) {
674
+ visit(key, [...path, key]);
675
+ walkStrings(item, [...path, key], visit);
676
+ }
677
+ }
678
+ }
679
+ /**
680
+ * Map the issues of a failed `CloudflareCheckupSnapshotV1Schema.safeParse` to
681
+ * the HTTP reason: the tripwire wins, then key-set errors, then everything else.
682
+ */
683
+ export function snapshotRejectReason(issues) {
684
+ const reasons = new Set(issues.map((i) => i.params?.reason));
685
+ if (reasons.has('secret_like_value'))
686
+ return 'secret_like_value';
687
+ if (reasons.has('snapshot_incomplete'))
688
+ return 'snapshot_incomplete';
689
+ return 'snapshot_invalid';
690
+ }
691
+ /**
692
+ * The `issues` array of a local-session error response: at most 20
693
+ * `{path, code}` pairs and never a value from the snapshot.
694
+ */
695
+ export function snapshotIssueList(issues) {
696
+ return issues.slice(0, 20).map((i) => ({
697
+ path: (i.path ?? []).map((p) => String(p)).join('.').slice(0, 200),
698
+ code: String((i.code === 'custom' ? i.message : i.code) ?? 'invalid').slice(0, 64),
699
+ }));
700
+ }
701
+ /** The scanner REST call a snapshot entry answers. The replay client derives the same key per call. */
702
+ export function snapshotRequestKey(kind, ids) {
703
+ const a = ids.accountId;
704
+ const z = ids.zoneId ?? '';
705
+ switch (kind) {
706
+ case 'account': return `GET /accounts/${a}`;
707
+ case 'zones': return `GET /zones?account.id=${a}`;
708
+ case 'account_members': return `GET /accounts/${a}/members`;
709
+ case 'account_rulesets': return `GET /accounts/${a}/rulesets`;
710
+ case 'account_access_rules': return `GET /accounts/${a}/firewall/access_rules/rules`;
711
+ case 'access_apps': return `GET /accounts/${a}/access/apps`;
712
+ case 'access_identity_providers': return `GET /accounts/${a}/access/identity_providers`;
713
+ case 'access_service_tokens': return `GET /accounts/${a}/access/service_tokens`;
714
+ case 'gateway_rules': return `GET /accounts/${a}/gateway/rules`;
715
+ case 'gateway_locations': return `GET /accounts/${a}/gateway/locations`;
716
+ case 'cfd_tunnels': return `GET /accounts/${a}/cfd_tunnel?is_deleted=false`;
717
+ case 'zone_setting': return `GET /zones/${z}/settings/${ids.settingId ?? ''}`;
718
+ case 'zone_dnssec': return `GET /zones/${z}/dnssec`;
719
+ case 'zone_dns_records': return `GET /zones/${z}/dns_records`;
720
+ case 'zone_universal_ssl': return `GET /zones/${z}/ssl/universal/settings`;
721
+ case 'zone_total_tls': return `GET /zones/${z}/acm/total_tls`;
722
+ case 'zone_rulesets': return `GET /zones/${z}/rulesets`;
723
+ case 'zone_ruleset': return `GET /zones/${z}/rulesets/${ids.rulesetId ?? ''}`;
724
+ case 'zone_access_rules': return `GET /zones/${z}/firewall/access_rules/rules`;
725
+ case 'zone_page_rules': return `GET /zones/${z}/pagerules`;
726
+ case 'zone_bot_management': return `GET /zones/${z}/bot_management`;
727
+ case 'zone_page_shield': return `GET /zones/${z}/page_shield`;
728
+ case 'zone_schema_validation_settings': return `GET /zones/${z}/schema_validation/settings`;
729
+ case 'zone_schema_validation_schemas': return `GET /zones/${z}/schema_validation/schemas`;
730
+ case 'zone_legacy_firewall_rules': return `GET /zones/${z}/firewall/rules`;
731
+ case 'zone_legacy_filters': return `GET /zones/${z}/filters`;
732
+ case 'zone_legacy_waf_packages': return `GET /zones/${z}/firewall/waf/packages`;
733
+ case 'zone_legacy_rate_limits': return `GET /zones/${z}/rate_limits`;
734
+ case 'zone_waf_analytics': return `POST /graphql#firewallEventsAdaptiveGroups/${z}`;
735
+ }
736
+ }
737
+ export function snapshotEntryKey(entry, accountId) {
738
+ return snapshotRequestKey(entry.kind, {
739
+ accountId,
740
+ zoneId: entry.zoneId,
741
+ settingId: entry.settingId,
742
+ rulesetId: entry.rulesetId,
743
+ });
744
+ }
745
+ /**
746
+ * Every key a complete snapshot holds, in canonical entry order: account kinds,
747
+ * then per zone (in list order) the 19 settings, the single kinds with each
748
+ * listed ruleset detail right after `zone_rulesets`, then the not-collectable
749
+ * kinds. `rulesetIdsByZone` holds only zones whose `zone_rulesets` is ok.
750
+ */
751
+ export function expectedEntryKeys(accountId, zones, rulesetIdsByZone) {
752
+ const keys = ACCOUNT_KINDS.map((kind) => snapshotRequestKey(kind, { accountId }));
753
+ for (const zone of zones) {
754
+ const zoneId = typeof zone === 'string' ? zone : zone.id;
755
+ for (const settingId of ZONE_SETTING_IDS)
756
+ keys.push(snapshotRequestKey('zone_setting', { accountId, zoneId, settingId }));
757
+ for (const kind of ZONE_SINGLE_KINDS) {
758
+ keys.push(snapshotRequestKey(kind, { accountId, zoneId }));
759
+ if (kind !== 'zone_rulesets')
760
+ continue;
761
+ const seen = new Set();
762
+ for (const rulesetId of rulesetIdsByZone[zoneId] ?? []) {
763
+ if (seen.has(rulesetId))
764
+ continue;
765
+ seen.add(rulesetId);
766
+ keys.push(snapshotRequestKey('zone_ruleset', { accountId, zoneId, rulesetId }));
767
+ }
768
+ }
769
+ for (const kind of NOT_COLLECTABLE_ZONE_KINDS)
770
+ keys.push(snapshotRequestKey(kind, { accountId, zoneId }));
771
+ }
772
+ return keys;
773
+ }
774
+ class ProjectionFailure {
775
+ detail;
776
+ constructor(detail) {
777
+ this.detail = detail;
778
+ }
779
+ }
780
+ const invalid = () => {
781
+ throw new ProjectionFailure('invalid_output');
782
+ };
783
+ const tooLarge = () => {
784
+ throw new ProjectionFailure('output_too_large');
785
+ };
786
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
787
+ function rawObject(raw) {
788
+ return isObject(raw) ? raw : invalid();
789
+ }
790
+ function rawItems(raw, max = SNAPSHOT_LIMITS.maxListItems) {
791
+ if (!Array.isArray(raw))
792
+ return invalid();
793
+ if (raw.length > max)
794
+ return tooLarge();
795
+ return raw.map((item) => rawObject(item));
796
+ }
797
+ function runProjector(schema, build) {
798
+ try {
799
+ const data = build();
800
+ // Every printed value is copied verbatim; anything the schema cannot
801
+ // represent fails the entry instead of being silently altered.
802
+ return schema.safeParse(data).success ? { ok: true, data } : { ok: false, detail: 'invalid_output' };
803
+ }
804
+ catch (error) {
805
+ if (error instanceof ProjectionFailure)
806
+ return { ok: false, detail: error.detail };
807
+ throw error;
808
+ }
809
+ }
810
+ const isToken = (value, max) => typeof value === 'string' && value.length <= max && TOKEN_RE.test(value);
811
+ /** Compared-only strings: tokens verbatim, `''` stays `''` (falsy), anything else becomes `'other'` (truthy). */
812
+ const comparedString = (value) => (isToken(value, 64) ? value : value === '' ? '' : 'other');
813
+ /** A field the scanner prints with an `x || fallback`: falsy values are omitted, the rest copied verbatim. */
814
+ const printedIfTruthy = (value) => (value ? value : undefined);
815
+ function assignDefined(target, values) {
816
+ for (const [key, value] of Object.entries(values)) {
817
+ if (value !== undefined)
818
+ target[key] = value;
819
+ }
820
+ return target;
821
+ }
822
+ /**
823
+ * `account`: `id, name, type, settings.enforce_twofactor`. createAccountMfaCheck
824
+ * (1110) compares `enforce_twofactor === true`; runInitPhase (4369-4381) and
825
+ * assembleCheckupResult (5127-5136) print `name`, `type` and `enforce_twofactor`.
826
+ */
827
+ export function projectAccount(raw) {
828
+ return runProjector(AccountData, () => {
829
+ const account = rawObject(raw);
830
+ const settings = isObject(account.settings) ? account.settings : undefined;
831
+ const out = assignDefined({}, { id: account.id, name: account.name, type: account.type });
832
+ if (settings && settings.enforce_twofactor !== undefined) {
833
+ out.settings = { enforce_twofactor: settings.enforce_twofactor };
834
+ }
835
+ return out;
836
+ });
837
+ }
838
+ /**
839
+ * `zones` (all pages merged, API order): `id, name, status, paused, type,
840
+ * development_mode, plan.name, account.{id,name}`. zoneCtxFor (4338) prints the
841
+ * account; createZoneStatusCheck (1960-1990), createDevModeCheck (2165-2197),
842
+ * createDNSModeCheck (3218-3234) and assembleCheckupResult (5099-5102) read the rest.
843
+ * `paused` is only tested for truthiness.
844
+ */
845
+ export function projectZones(raw) {
846
+ return runProjector(z.array(ZoneData).max(SNAPSHOT_LIMITS.maxZones), () => rawItems(raw, SNAPSHOT_LIMITS.maxZones).map((zone) => {
847
+ const plan = isObject(zone.plan) ? zone.plan : undefined;
848
+ const account = isObject(zone.account) ? zone.account : invalid();
849
+ const out = assignDefined({}, {
850
+ id: zone.id,
851
+ name: zone.name,
852
+ status: zone.status,
853
+ paused: zone.paused === undefined || typeof zone.paused === 'boolean' ? zone.paused : Boolean(zone.paused),
854
+ // A null `type` reads as full setup and a null `development_mode` as
855
+ // disabled; neither is printed then, so null is the same as absent.
856
+ type: zone.type ?? undefined,
857
+ development_mode: zone.development_mode ?? undefined,
858
+ });
859
+ if (plan && printedIfTruthy(plan.name) !== undefined)
860
+ out.plan = { name: plan.name };
861
+ out.account = { id: account.id, name: account.name };
862
+ return out;
863
+ }));
864
+ }
865
+ const MEMBER_EMAIL_LIMIT = 8;
866
+ /**
867
+ * `account_members`: one item per member, in order. createMemberMfaCheck and
868
+ * createPendingMembersCheck (1162-1198, 1224-1252) read only `status ===
869
+ * 'pending'`, `two_factor_authentication_enabled === false`, `.length`, and the
870
+ * first 8 truthy `m.email || m.user.email` of each selection.
871
+ */
872
+ export function projectAccountMembers(raw) {
873
+ return runProjector(list(MemberData), () => {
874
+ let pendingEmails = 0;
875
+ let without2faEmails = 0;
876
+ return rawItems(raw).map((member) => {
877
+ const out = {};
878
+ const user = isObject(member.user) ? member.user : undefined;
879
+ const pending = member.status === 'pending';
880
+ const without2fa = user?.two_factor_authentication_enabled === false;
881
+ if (pending)
882
+ out.status = 'pending';
883
+ const address = member.email || user?.email;
884
+ if (address && pending && pendingEmails < MEMBER_EMAIL_LIMIT) {
885
+ out.email = address;
886
+ pendingEmails++;
887
+ }
888
+ else if (address && !pending && without2fa && without2faEmails < MEMBER_EMAIL_LIMIT) {
889
+ out.email = address;
890
+ without2faEmails++;
891
+ }
892
+ if (without2fa)
893
+ out.user = { two_factor_authentication_enabled: false };
894
+ return out;
895
+ });
896
+ });
897
+ }
898
+ /**
899
+ * `account_rulesets`, `access_apps`, `gateway_locations`,
900
+ * `zone_schema_validation_schemas`: only `.length` is read (1259-1297, 1421,
901
+ * 1744, 3872), so only the count leaves the machine.
902
+ */
903
+ export function projectCounted(raw) {
904
+ return runProjector(counted, () => ({ count: rawItems(raw).length }));
905
+ }
906
+ const ACCESS_RULE_DETAIL_LIMIT = 5;
907
+ function accessRuleValue(value) {
908
+ if (!value)
909
+ return undefined;
910
+ // `${config.value}` is printed; a numeric value (an ASN) prints identically as a string.
911
+ return typeof value === 'number' && Number.isFinite(value) ? String(value) : value;
912
+ }
913
+ function projectAccessRules(raw, isBroad, broadOnly) {
914
+ return runProjector(list(AccessRuleData), () => {
915
+ let broadSeen = 0;
916
+ return rawItems(raw).map((rule) => {
917
+ const config = isObject(rule.configuration) ? rule.configuration : undefined;
918
+ if (!isBroad(rule, config))
919
+ return {};
920
+ broadSeen++;
921
+ if (broadSeen > ACCESS_RULE_DETAIL_LIMIT)
922
+ return broadOnly(rule, config);
923
+ // `${rule.mode || 'unknown'} ${config.target || 'target'} ${config.value || ''}` is printed.
924
+ const out = assignDefined({}, { mode: printedIfTruthy(rule.mode) });
925
+ const configuration = assignDefined({}, {
926
+ target: printedIfTruthy(config?.target),
927
+ value: accessRuleValue(config?.value),
928
+ });
929
+ if (Object.keys(configuration).length > 0)
930
+ out.configuration = configuration;
931
+ return out;
932
+ });
933
+ });
934
+ }
935
+ /**
936
+ * `account_access_rules`. createAccountAccessRulesCheck (1318-1354): broad =
937
+ * `mode === 'whitelist' || target === 'country' || target === 'asn'`; prints
938
+ * `broad.length`, `accessRules.length` and the first 5 broad rules. Later broad
939
+ * rules keep only the fields that make them broad; other rules are `{}`.
940
+ */
941
+ export function projectAccountAccessRules(raw) {
942
+ return projectAccessRules(raw, (rule, config) => String(rule.mode || '') === 'whitelist' || config?.target === 'country' || config?.target === 'asn', (rule, config) => {
943
+ const out = {};
944
+ const target = config?.target;
945
+ if (String(rule.mode || '') === 'whitelist')
946
+ out.mode = 'whitelist';
947
+ if (target === 'country' || target === 'asn')
948
+ out.configuration = { target };
949
+ return out;
950
+ });
951
+ }
952
+ const ZONE_BROAD_TARGETS = ['ip', 'ip_range', 'country', 'asn'];
953
+ const ZONE_BROAD_VALUES = ['0.0.0.0/0', '::/0'];
954
+ /**
955
+ * `zone_access_rules`. createZoneIpAccessRulesCheck (2613-2673): broad =
956
+ * `target.toLowerCase() ∈ {ip, ip_range, country, asn} || value ∈ {0.0.0.0/0, ::/0}`.
957
+ * Same first-5 rule as the account list.
958
+ */
959
+ export function projectZoneAccessRules(raw) {
960
+ const broadTarget = (config) => ZONE_BROAD_TARGETS.includes(String(config?.target || '').toLowerCase());
961
+ const broadValue = (config) => ZONE_BROAD_VALUES.includes(String(config?.value || ''));
962
+ return projectAccessRules(raw, (_rule, config) => broadTarget(config) || broadValue(config), (_rule, config) => {
963
+ const configuration = {};
964
+ if (broadTarget(config))
965
+ configuration.target = config?.target;
966
+ if (broadValue(config))
967
+ configuration.value = config?.value;
968
+ return { configuration };
969
+ });
970
+ }
971
+ /**
972
+ * `access_identity_providers`: `{type}`. createAccessIdentityProvidersCheck
973
+ * (1515-1540) reads `.length` and compares `type.toLowerCase()` with fixed
974
+ * literals; `type` is never printed, so non-token types become `'other'`.
975
+ */
976
+ export function projectIdentityProviders(raw) {
977
+ return runProjector(list(IdentityProviderData), () => rawItems(raw).map((idp) => {
978
+ if (typeof idp.type !== 'string' || idp.type === '')
979
+ return {};
980
+ return { type: isToken(idp.type, 64) ? idp.type : 'other' };
981
+ }));
982
+ }
983
+ const ISO_MIN = '0000-01-01T00:00:00.000Z';
984
+ const ISO_MAX = '9999-12-31T23:59:59.999Z';
985
+ /**
986
+ * `access_service_tokens`: `expires_at`. createAccessServiceTokensCheck
987
+ * (1587-1593) only evaluates `Date.parse(expires_at || expiresAt) < now` for
988
+ * string values, so a parseable value is normalised to ISO (extended years are
989
+ * clamped to the 4-digit range, which keeps the comparison) and anything else
990
+ * is dropped (both give "not expired").
991
+ */
992
+ export function projectServiceTokens(raw) {
993
+ return runProjector(list(ServiceTokenData), () => rawItems(raw).map((serviceToken) => {
994
+ const exp = serviceToken.expires_at || serviceToken.expiresAt;
995
+ if (typeof exp !== 'string')
996
+ return {};
997
+ const time = Date.parse(exp);
998
+ if (!Number.isFinite(time))
999
+ return {};
1000
+ const isoTime = new Date(time).toISOString();
1001
+ const clamped = /^\d{4}-/.test(isoTime) ? isoTime : time < 0 ? ISO_MIN : ISO_MAX;
1002
+ return { expires_at: clamped };
1003
+ }));
1004
+ }
1005
+ /**
1006
+ * `gateway_rules`: `enabled:false` iff `=== false`, `filters` verbatim.
1007
+ * createGatewayRulesCheck (1655-1680) counts `enabled !== false` and prints
1008
+ * each filter with its count.
1009
+ */
1010
+ export function projectGatewayRules(raw) {
1011
+ return runProjector(list(GatewayRuleData), () => rawItems(raw).map((rule) => {
1012
+ const out = {};
1013
+ if (rule.enabled === false)
1014
+ out.enabled = false;
1015
+ if (Array.isArray(rule.filters) && rule.filters.length > 0)
1016
+ out.filters = rule.filters;
1017
+ return out;
1018
+ }));
1019
+ }
1020
+ /**
1021
+ * `cfd_tunnels`: `status` and `is_deleted:true` iff `deleted_at || is_deleted
1022
+ * === true`. createCloudflareTunnelsCheck (1801-1814) only compares
1023
+ * `status.toLowerCase()` with fixed literals, so non-token statuses become `'other'`.
1024
+ */
1025
+ export function projectTunnels(raw) {
1026
+ return runProjector(list(TunnelData), () => rawItems(raw).map((tunnel) => {
1027
+ const out = {};
1028
+ if (typeof tunnel.status === 'string' && tunnel.status !== '') {
1029
+ out.status = isToken(tunnel.status, 32) ? tunnel.status : 'other';
1030
+ }
1031
+ if (tunnel.deleted_at || tunnel.is_deleted === true)
1032
+ out.is_deleted = true;
1033
+ return out;
1034
+ }));
1035
+ }
1036
+ /**
1037
+ * `zone_setting`: `value` only, verbatim. Every setting check reads
1038
+ * `statusValue(read)` (= `.value`, 1053-1056) or `wafSetting.data.value`
1039
+ * (2324, 3535); minify (2905-2953) and HSTS (3695-3727) print sub-keys at
1040
+ * depth ≤ 2. Values are printed, so a non-token string, an array or a deeper
1041
+ * object fails the entry rather than being altered.
1042
+ */
1043
+ export function projectZoneSetting(raw) {
1044
+ return runProjector(SettingData, () => {
1045
+ // `statusValue` reads `read.data?.value`, so a null result reads like an absent value.
1046
+ if (raw === null)
1047
+ return {};
1048
+ const value = rawObject(raw).value;
1049
+ return (value === undefined ? {} : { value });
1050
+ });
1051
+ }
1052
+ /**
1053
+ * `zone_dnssec`: `status, algorithm`. createDNSSECCheck (2114-2160) compares
1054
+ * and prints `status.toLowerCase()` and prints a truthy `algorithm`.
1055
+ */
1056
+ export function projectDnssec(raw) {
1057
+ return runProjector(DnssecData, () => {
1058
+ const dnssec = rawObject(raw);
1059
+ return assignDefined({}, {
1060
+ status: printedIfTruthy(dnssec.status),
1061
+ algorithm: printedIfTruthy(dnssec.algorithm),
1062
+ });
1063
+ });
1064
+ }
1065
+ /**
1066
+ * `zone_dns_records` (all pages, API order): only `(A|AAAA) && content` records.
1067
+ * findOriginExposure and detectOriginIpReuseAcrossZones (3307-3373) read
1068
+ * `type`, `content` (as a Map key and through `maskIp`), `proxied` (truthiness)
1069
+ * and `name`, which is printed only for non-proxied records (3292-3297); proxied
1070
+ * names are only counted. `content` stays raw here and is pseudonymised by
1071
+ * `createIpPseudonymizer` (via `assembleSnapshot`) once every zone is collected.
1072
+ */
1073
+ export function projectDnsRecords(raw) {
1074
+ return runProjector(list(DnsRecordData), () => {
1075
+ const records = rawItems(raw, Number.MAX_SAFE_INTEGER)
1076
+ .filter((record) => (record.type === 'A' || record.type === 'AAAA') && record.content)
1077
+ .map((record) => {
1078
+ const out = { type: record.type, content: record.content };
1079
+ if (record.proxied)
1080
+ out.proxied = true;
1081
+ else if (record.name)
1082
+ out.name = record.name;
1083
+ return out;
1084
+ });
1085
+ return records.length > SNAPSHOT_LIMITS.maxListItems ? tooLarge() : records;
1086
+ });
1087
+ }
1088
+ /**
1089
+ * `zone_universal_ssl`, `zone_total_tls`, `zone_page_shield`: `enabled`.
1090
+ * The checks read `enabled !== false` (3797) and `enabled === true` (2697,
1091
+ * 3832); a non-boolean `enabled` gives the same outcome as an absent one.
1092
+ */
1093
+ export function projectEnabledOnly(raw) {
1094
+ return runProjector(enabledOnly, () => {
1095
+ const value = rawObject(raw);
1096
+ return typeof value.enabled === 'boolean' ? { enabled: value.enabled } : {};
1097
+ });
1098
+ }
1099
+ function rulesetHead(ruleset) {
1100
+ const out = { id: ruleset.id };
1101
+ if (ruleset.phase !== undefined && ruleset.phase !== null)
1102
+ out.phase = ruleset.phase;
1103
+ if (typeof ruleset.name === 'string' && ruleset.name.toLowerCase().includes('ddos'))
1104
+ out.name = 'ddos';
1105
+ return out;
1106
+ }
1107
+ /**
1108
+ * `zone_rulesets` heads: `id, phase`, `name:'ddos'` iff the name contains
1109
+ * "ddos"; `kind` and `version` are dropped. `name` is read only by
1110
+ * createDDoSCheck (2511); `phase` at 2326, 2511, 2559-2564, 3446 and 4019.
1111
+ */
1112
+ export function projectRulesetHeads(raw) {
1113
+ return runProjector(z.array(RulesetHeadData).max(200), () => rawItems(raw, 200).map((head) => rulesetHead(head)));
1114
+ }
1115
+ const isManagedPhase = (phase) => phase !== undefined && phase.includes('managed');
1116
+ /**
1117
+ * How many `wafAnalysis.issues` createWAFAnalysisFromRulesets (3979-4047)
1118
+ * reports for one rule: one when it is disabled in a managed phase, one when
1119
+ * it blocks everything (`block` with expression `true`), so 0, 1 or 2.
1120
+ */
1121
+ function wafIssueCount(rule, managedPhase) {
1122
+ const action = typeof rule.action === 'string' ? rule.action.toLowerCase() : undefined;
1123
+ const disabledManaged = rule.enabled === false && managedPhase;
1124
+ const blocksAll = action === 'block' && typeof rule.expression === 'string' && rule.expression.trim().toLowerCase() === 'true';
1125
+ return (disabledManaged ? 1 : 0) + (blocksAll ? 1 : 0);
1126
+ }
1127
+ function isIssueRule(rule, phase) {
1128
+ return wafIssueCount(rule, isManagedPhase(phase)) > 0;
1129
+ }
1130
+ function projectRule(rule, phase) {
1131
+ const out = {};
1132
+ const action = typeof rule.action === 'string' ? rule.action : undefined;
1133
+ if (action !== undefined)
1134
+ out.action = RULE_ACTION_RE.test(action) ? action : 'other';
1135
+ if (rule.enabled === false)
1136
+ out.enabled = false;
1137
+ if (action?.toLowerCase() === 'block' && typeof rule.expression === 'string') {
1138
+ const expression = rule.expression.toLowerCase();
1139
+ if (expression.trim() === 'true')
1140
+ out.expression = 'true';
1141
+ else if (GEOIP_BLOCK_RE.test(expression))
1142
+ out.expression = 'ip.geoip.country in';
1143
+ }
1144
+ if (action === 'execute' && isObject(rule.action_parameters)) {
1145
+ const params = rule.action_parameters;
1146
+ const executeId = String(params.id || '');
1147
+ const executeName = String(params.name || '').toLowerCase();
1148
+ const needles = MANAGED_NAME_NEEDLES.filter((needle) => executeName.includes(needle));
1149
+ const actionParameters = {};
1150
+ if (MANAGED_RULESET_IDS.includes(executeId))
1151
+ actionParameters.id = executeId;
1152
+ if (needles.length > 0)
1153
+ actionParameters.name = needles.join(' | ');
1154
+ if (Object.keys(actionParameters).length > 0)
1155
+ out.action_parameters = actionParameters;
1156
+ }
1157
+ if (isIssueRule(rule, phase)) {
1158
+ // createWAFAnalysisFromRulesets prints `ruleId: rule.id` and
1159
+ // `ruleName: rule.description || rule.ref || 'Unnamed rule'` (4014-4034).
1160
+ if (rule.id !== undefined)
1161
+ out.id = rule.id;
1162
+ if (rule.description)
1163
+ out.description = rule.description;
1164
+ else if (rule.ref)
1165
+ out.ref = rule.ref;
1166
+ }
1167
+ return out;
1168
+ }
1169
+ /**
1170
+ * `zone_ruleset` detail: the head fields plus projected rules (not yet
1171
+ * encoded). Rules feed analyzeRulesets (3425-3460), createWAFAnalysisFromRulesets
1172
+ * (3979-4047), hasExecutedRuleset (2412-2429) and the rate-limiting count (2559-2564):
1173
+ * - `action` raw if `^[A-Za-z_]{1,32}$`, else `'other'` (never equal to a compared action);
1174
+ * - `enabled:false` iff `=== false`;
1175
+ * - `expression` only on block rules: `'true'` or the geoip sentinel, else omitted;
1176
+ * - `action_parameters` only on `execute`: a managed `id` and the matched name needles;
1177
+ * - `id`, `description` (or `ref` when `description` is empty) only on issue rules.
1178
+ * More than `maxRulesPerBody` rules, or more than `maxRunsPerBody` runs, fails
1179
+ * with `output_too_large`.
1180
+ */
1181
+ export function projectRulesetDetail(raw) {
1182
+ return runProjector(ProjectedRulesetDetailData, () => {
1183
+ const ruleset = rawObject(raw);
1184
+ const head = rulesetHead(ruleset);
1185
+ const phase = typeof ruleset.phase === 'string' ? ruleset.phase : undefined;
1186
+ const rawRules = ruleset.rules === undefined || ruleset.rules === null ? [] : ruleset.rules;
1187
+ if (!Array.isArray(rawRules))
1188
+ return invalid();
1189
+ if (rawRules.length > SNAPSHOT_LIMITS.maxRulesPerBody)
1190
+ return tooLarge();
1191
+ const rules = rawRules.map((rule) => projectRule(rawObject(rule), phase));
1192
+ if (encodeRuleRuns(rules).length > SNAPSHOT_LIMITS.maxRunsPerBody)
1193
+ return tooLarge();
1194
+ return { ...head, rules };
1195
+ });
1196
+ }
1197
+ /**
1198
+ * `zone_page_rules`: one item per rule; `actions[].id` filtered to the five
1199
+ * legacy IDs (first occurrence order). createPageRulesCheck (3601-3650) reads
1200
+ * `.length` and detectDeprecatedPageRuleActions.
1201
+ */
1202
+ export function projectPageRules(raw) {
1203
+ return runProjector(list(PageRuleData), () => rawItems(raw).map((rule) => {
1204
+ const ids = [];
1205
+ for (const action of Array.isArray(rule.actions) ? rule.actions : []) {
1206
+ const id = isObject(action) ? String(action.id || '') : '';
1207
+ if (LEGACY_PAGE_RULE_ACTIONS.includes(id) && !ids.includes(id))
1208
+ ids.push(id);
1209
+ }
1210
+ return (ids.length > 0 ? { actions: ids.map((id) => ({ id })) } : {});
1211
+ }));
1212
+ }
1213
+ function comparedPrimitives(source, keep) {
1214
+ const out = {};
1215
+ for (const [key, value] of Object.entries(source)) {
1216
+ if (!isToken(key, 64))
1217
+ return invalid();
1218
+ const kept = keep(key, value);
1219
+ if (kept !== undefined)
1220
+ out[key] = kept;
1221
+ }
1222
+ return out;
1223
+ }
1224
+ function comparedPrimitive(value) {
1225
+ if (typeof value === 'string')
1226
+ return comparedString(value);
1227
+ if (value === null || typeof value === 'boolean' || typeof value === 'number')
1228
+ return value;
1229
+ return undefined; // objects and arrays never equal a compared literal
1230
+ }
1231
+ /**
1232
+ * `zone_bot_management`. createBotManagementCheck (2815-2850) prints
1233
+ * `Boolean(fight_mode)` and `Boolean(enable_js)` (kept as `!!value`), tests
1234
+ * `Object.values(...).some(v === 'block' || v === 'enabled')` (other strings
1235
+ * become `''` or `'other'`, preserving truthiness) and prints
1236
+ * `Object.keys(stale_zone_configuration).join(', ')`. That key list is kept as
1237
+ * an array in exactly the `Object.keys` order the live scanner sees (integer-like
1238
+ * keys first, then insertion order); the replay client turns it back into
1239
+ * `{key: true}`. An object would lose the order, because the serialiser sorts
1240
+ * object keys.
1241
+ */
1242
+ export function projectBotManagement(raw) {
1243
+ return runProjector(BotManagementData, () => comparedPrimitives(rawObject(raw), (key, value) => {
1244
+ if (key === 'fight_mode' || key === 'enable_js')
1245
+ return Boolean(value);
1246
+ if (key === 'stale_zone_configuration' && value !== null && typeof value === 'object') {
1247
+ return Object.keys(value);
1248
+ }
1249
+ return comparedPrimitive(value);
1250
+ }));
1251
+ }
1252
+ /**
1253
+ * `zone_schema_validation_settings`: top-level primitives (same string rule as
1254
+ * bot management). createApiShieldCheck (3858-3883) compares values with
1255
+ * `'block'`, `'log'` and `true`.
1256
+ */
1257
+ export function projectSchemaValidationSettings(raw) {
1258
+ return runProjector(SchemaValidationSettingsData, () => comparedPrimitives(rawObject(raw), (_key, value) => comparedPrimitive(value)));
1259
+ }
1260
+ /**
1261
+ * One projector per collectable kind. Each takes the raw `cf` JSON exactly as
1262
+ * printed (the bare result object, or for lists the bare arrays of every page
1263
+ * concatenated in API order) and returns the snapshot data or a
1264
+ * `collection_failed` detail.
1265
+ */
1266
+ export const PROJECTORS = {
1267
+ account: projectAccount,
1268
+ zones: projectZones,
1269
+ account_members: projectAccountMembers,
1270
+ account_rulesets: projectCounted,
1271
+ account_access_rules: projectAccountAccessRules,
1272
+ access_apps: projectCounted,
1273
+ access_identity_providers: projectIdentityProviders,
1274
+ access_service_tokens: projectServiceTokens,
1275
+ gateway_rules: projectGatewayRules,
1276
+ gateway_locations: projectCounted,
1277
+ cfd_tunnels: projectTunnels,
1278
+ zone_setting: projectZoneSetting,
1279
+ zone_dnssec: projectDnssec,
1280
+ zone_dns_records: projectDnsRecords,
1281
+ zone_universal_ssl: projectEnabledOnly,
1282
+ zone_total_tls: projectEnabledOnly,
1283
+ zone_rulesets: projectRulesetHeads,
1284
+ zone_ruleset: projectRulesetDetail,
1285
+ zone_access_rules: projectZoneAccessRules,
1286
+ zone_page_rules: projectPageRules,
1287
+ zone_bot_management: projectBotManagement,
1288
+ zone_page_shield: projectEnabledOnly,
1289
+ zone_schema_validation_settings: projectSchemaValidationSettings,
1290
+ zone_schema_validation_schemas: projectCounted,
1291
+ };
1292
+ export const NOT_COLLECTED_RESULT = Object.freeze({ ok: false, reason: 'not_collected' });
1293
+ export function httpErrorResult(status) {
1294
+ return { ok: false, reason: 'http_error', status };
1295
+ }
1296
+ export function collectionFailedResult(detail) {
1297
+ return { ok: false, reason: 'collection_failed', detail };
1298
+ }
1299
+ /** Turn a projector outcome into an entry `result`. */
1300
+ export function projectionToResult(projection) {
1301
+ return projection.ok ? { ok: true, data: projection.data } : collectionFailedResult(projection.detail);
1302
+ }
1303
+ // ============================================================================
1304
+ // Rule runs and bodies
1305
+ // ============================================================================
1306
+ function canonicalJson(value) {
1307
+ if (Array.isArray(value))
1308
+ return `[${value.map(canonicalJson).join(',')}]`;
1309
+ if (value !== null && typeof value === 'object') {
1310
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
1311
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
1312
+ }
1313
+ return JSON.stringify(value);
1314
+ }
1315
+ /** Run-length encode consecutive identical projected rules into `{...rule, n}`. */
1316
+ export function encodeRuleRuns(rules) {
1317
+ const runs = [];
1318
+ let previous;
1319
+ for (const rule of rules) {
1320
+ const canonical = canonicalJson(rule);
1321
+ const last = runs[runs.length - 1];
1322
+ if (last !== undefined && canonical === previous) {
1323
+ last.n = (last.n ?? 1) + 1;
1324
+ }
1325
+ else {
1326
+ runs.push({ ...rule });
1327
+ previous = canonical;
1328
+ }
1329
+ }
1330
+ return runs.map(({ n, ...rule }) => (n === undefined ? rule : { ...rule, n }));
1331
+ }
1332
+ /** Restore the exact rule order and count of a body; every rule is a fresh object. */
1333
+ export function expandRuleRuns(runs) {
1334
+ const rules = [];
1335
+ for (const { n, ...rule } of runs) {
1336
+ for (let i = 0; i < (n ?? 1); i++)
1337
+ rules.push(JSON.parse(JSON.stringify(rule)));
1338
+ }
1339
+ return rules;
1340
+ }
1341
+ export function createRuleBodyEncoder() {
1342
+ const refsByContent = new Map();
1343
+ const bodies = {};
1344
+ return {
1345
+ add(rules) {
1346
+ if (rules.length === 0)
1347
+ return undefined;
1348
+ const runs = encodeRuleRuns(rules);
1349
+ const content = canonicalJson(runs);
1350
+ const existing = refsByContent.get(content);
1351
+ if (existing !== undefined)
1352
+ return existing;
1353
+ const ref = `r${refsByContent.size}`;
1354
+ refsByContent.set(content, ref);
1355
+ bodies[ref] = runs;
1356
+ return ref;
1357
+ },
1358
+ bodies: () => bodies,
1359
+ };
1360
+ }
1361
+ // ============================================================================
1362
+ // IP pseudonymisation (`per_prefix_sequential/1`)
1363
+ // ============================================================================
1364
+ const MAX_PSEUDONYMS_PER_PREFIX = 65_535;
1365
+ /**
1366
+ * Per-prefix sequential pseudonyms, applied in zone list order and record API
1367
+ * order. IPv6 (any `:`) keeps its first three `:`-groups and becomes
1368
+ * `${prefix}:ffff:${n.toString(16)}`; IPv4 (4 dot parts) keeps `a.b` and
1369
+ * becomes `a.b.${n >> 8}.${n & 255}`; `n` counts from 1 per prefix. The mapping
1370
+ * is a bijection on the snapshot, keeps Map insertion order, distinct prefixes
1371
+ * never collide, and scanner `maskIp` (4234-4240) output is byte-identical.
1372
+ */
1373
+ export function createIpPseudonymizer() {
1374
+ const memo = new Map();
1375
+ const counters = new Map();
1376
+ return {
1377
+ pseudonymize(content) {
1378
+ const known = memo.get(content);
1379
+ if (known !== undefined)
1380
+ return { ok: true, data: known };
1381
+ if (content.length > 64 || !IP_CONTENT_RE.test(content))
1382
+ return { ok: false, detail: 'invalid_output' };
1383
+ let family;
1384
+ let prefix;
1385
+ if (content.includes(':')) {
1386
+ const groups = content.split(':');
1387
+ // Every IPv6 text form splits into at least three groups ("::" does);
1388
+ // fewer would change maskIp's three-group prefix.
1389
+ if (groups.length < 3)
1390
+ return { ok: false, detail: 'invalid_output' };
1391
+ family = 6;
1392
+ prefix = groups.slice(0, 3).join(':');
1393
+ }
1394
+ else {
1395
+ const parts = content.split('.');
1396
+ if (parts.length !== 4)
1397
+ return { ok: false, detail: 'invalid_output' };
1398
+ family = 4;
1399
+ prefix = `${parts[0]}.${parts[1]}`;
1400
+ }
1401
+ const counterKey = `${family}|${prefix}`;
1402
+ const n = (counters.get(counterKey) ?? 0) + 1;
1403
+ if (n > MAX_PSEUDONYMS_PER_PREFIX)
1404
+ return { ok: false, detail: 'output_too_large' };
1405
+ const pseudonym = family === 6 ? `${prefix}:ffff:${n.toString(16)}` : `${prefix}.${n >> 8}.${n & 255}`;
1406
+ if (pseudonym.length > 64)
1407
+ return { ok: false, detail: 'invalid_output' };
1408
+ counters.set(counterKey, n);
1409
+ memo.set(content, pseudonym);
1410
+ return { ok: true, data: pseudonym };
1411
+ },
1412
+ };
1413
+ }
1414
+ /**
1415
+ * Build the snapshot from collected entries. In order:
1416
+ * 1. Collector rule: a zone with any `collection_failed` ruleset detail gets its
1417
+ * `zone_rulesets` rewritten to `collection_failed` with that detail (first
1418
+ * failure in list order) and keeps no `zone_ruleset` entries, so the scanner
1419
+ * never falls back to rule-less heads (874-891).
1420
+ * 2. IP pseudonymisation over `zone_dns_records` in zone list order and record
1421
+ * order; a list with unrepresentable content becomes `collection_failed`.
1422
+ * 3. Canonical entry order (`expectedEntryKeys`); unexpected entries go last.
1423
+ * 4. Rule bodies encoded and deduplicated in that order.
1424
+ * The result is not validated: parse it with `CloudflareCheckupSnapshotV1Schema`.
1425
+ */
1426
+ export function assembleSnapshot(input) {
1427
+ const { accountId } = input;
1428
+ const zonesEntry = input.entries.find((entry) => entry.kind === 'zones');
1429
+ if (!zonesEntry || zonesEntry.kind !== 'zones')
1430
+ throw new Error('assembleSnapshot requires the zones entry');
1431
+ const zoneIds = zonesEntry.result.data.map((zone) => zone.id);
1432
+ // 1. Collector rule for failed ruleset details.
1433
+ const headOrder = new Map();
1434
+ for (const entry of input.entries) {
1435
+ if (entry.kind === 'zone_rulesets' && entry.result.ok)
1436
+ headOrder.set(entry.zoneId, entry.result.data.map((head) => head.id));
1437
+ }
1438
+ const failedDetailByZone = new Map();
1439
+ const failures = input.entries
1440
+ .filter((entry) => entry.kind === 'zone_ruleset' && !entry.result.ok && entry.result.reason === 'collection_failed')
1441
+ .sort((a, b) => (headOrder.get(a.zoneId) ?? []).indexOf(a.rulesetId) - (headOrder.get(b.zoneId) ?? []).indexOf(b.rulesetId));
1442
+ for (const failure of failures) {
1443
+ if (headOrder.has(failure.zoneId) && !failedDetailByZone.has(failure.zoneId)) {
1444
+ failedDetailByZone.set(failure.zoneId, failure.result.detail);
1445
+ }
1446
+ }
1447
+ let entries = input.entries
1448
+ .filter((entry) => !(entry.kind === 'zone_ruleset' && (failedDetailByZone.has(entry.zoneId) || !headOrder.has(entry.zoneId))))
1449
+ .map((entry) => {
1450
+ if (entry.kind !== 'zone_rulesets')
1451
+ return entry;
1452
+ const detail = failedDetailByZone.get(entry.zoneId);
1453
+ return detail === undefined ? entry : { kind: 'zone_rulesets', zoneId: entry.zoneId, result: collectionFailedResult(detail) };
1454
+ });
1455
+ // 2. IP pseudonymisation.
1456
+ const pseudonymizer = createIpPseudonymizer();
1457
+ const pseudonymized = new Map();
1458
+ for (const zoneId of zoneIds) {
1459
+ for (const entry of entries) {
1460
+ if (entry.kind !== 'zone_dns_records' || entry.zoneId !== zoneId || !entry.result.ok)
1461
+ continue;
1462
+ const records = entry.result.data;
1463
+ const invalidContent = records.some((record) => !pseudonymFormatOk(record.content));
1464
+ let failed = invalidContent ? 'invalid_output' : undefined;
1465
+ const mapped = [];
1466
+ for (const record of failed ? [] : records) {
1467
+ const pseudonym = pseudonymizer.pseudonymize(record.content);
1468
+ if (!pseudonym.ok) {
1469
+ failed = pseudonym.detail;
1470
+ break;
1471
+ }
1472
+ mapped.push({ ...record, content: pseudonym.data });
1473
+ }
1474
+ pseudonymized.set(entry, {
1475
+ kind: 'zone_dns_records',
1476
+ zoneId,
1477
+ result: failed ? collectionFailedResult(failed) : { ok: true, data: mapped },
1478
+ });
1479
+ }
1480
+ }
1481
+ entries = entries.map((entry) => pseudonymized.get(entry) ?? entry);
1482
+ // 3. Canonical order.
1483
+ const listed = {};
1484
+ for (const entry of entries) {
1485
+ if (entry.kind === 'zone_rulesets' && entry.result.ok)
1486
+ listed[entry.zoneId] = entry.result.data.map((head) => head.id);
1487
+ }
1488
+ const rank = new Map(expectedEntryKeys(accountId, zoneIds, listed).map((key, index) => [key, index]));
1489
+ const ordered = entries
1490
+ .map((entry, index) => ({ entry, index, rank: rank.get(snapshotEntryKey(entry, accountId)) ?? Number.MAX_SAFE_INTEGER }))
1491
+ .sort((a, b) => a.rank - b.rank || a.index - b.index)
1492
+ .map(({ entry }) => entry);
1493
+ // 4. Rule bodies.
1494
+ const encoder = createRuleBodyEncoder();
1495
+ const finalEntries = ordered.map((entry) => {
1496
+ if (entry.kind !== 'zone_ruleset')
1497
+ return canonicalEntry(entry);
1498
+ if (!entry.result.ok) {
1499
+ return { kind: 'zone_ruleset', zoneId: entry.zoneId, rulesetId: entry.rulesetId, result: entry.result };
1500
+ }
1501
+ const { rules, ...head } = entry.result.data;
1502
+ const rulesBody = encoder.add(rules);
1503
+ const data = rulesBody === undefined ? { ...head } : { ...head, rulesBody };
1504
+ return { kind: 'zone_ruleset', zoneId: entry.zoneId, rulesetId: entry.rulesetId, result: { ok: true, data } };
1505
+ });
1506
+ return {
1507
+ schemaVersion: SNAPSHOT_SCHEMA_VERSION,
1508
+ projection: { version: SNAPSHOT_PROJECTION_VERSION, ipPseudonymization: IP_PSEUDONYMIZATION },
1509
+ accountId,
1510
+ collector: canonicalCollector(input.collector),
1511
+ entries: finalEntries,
1512
+ ruleBodies: encoder.bodies(),
1513
+ };
1514
+ }
1515
+ function pseudonymFormatOk(content) {
1516
+ if (content.length > 64 || !IP_CONTENT_RE.test(content))
1517
+ return false;
1518
+ return content.includes(':') ? content.split(':').length >= 3 : content.split('.').length === 4;
1519
+ }
1520
+ /** Rebuild an entry with a stable key order: kind, zoneId, settingId/rulesetId, result. */
1521
+ function canonicalEntry(entry) {
1522
+ const out = { kind: entry.kind };
1523
+ if ('zoneId' in entry)
1524
+ out.zoneId = entry.zoneId;
1525
+ if ('settingId' in entry)
1526
+ out.settingId = entry.settingId;
1527
+ if ('rulesetId' in entry)
1528
+ out.rulesetId = entry.rulesetId;
1529
+ const r = entry.result;
1530
+ out.result = r.ok
1531
+ ? { ok: true, data: r.data }
1532
+ : r.reason === 'http_error'
1533
+ ? { ok: false, reason: 'http_error', status: r.status }
1534
+ : r.reason === 'collection_failed'
1535
+ ? { ok: false, reason: 'collection_failed', detail: r.detail }
1536
+ : { ok: false, reason: r.reason };
1537
+ return out;
1538
+ }
1539
+ function canonicalCollector(collector) {
1540
+ return {
1541
+ name: collector.name,
1542
+ version: collector.version,
1543
+ cf: { version: collector.cf.version },
1544
+ authSource: collector.authSource,
1545
+ platform: collector.platform,
1546
+ startedAt: collector.startedAt,
1547
+ finishedAt: collector.finishedAt,
1548
+ cfInvocations: collector.cfInvocations,
1549
+ };
1550
+ }
1551
+ /**
1552
+ * Stable snapshot formatting (§F7): top-level keys in a fixed order, one
1553
+ * compact entry and one compact rule body per line, trailing newline. Entry
1554
+ * keys keep a fixed order and data keys are sorted, so the output depends only
1555
+ * on content (`serializeSnapshot(parse(text)) === text`). The output is the
1556
+ * exact byte sequence the CLI writes, hashes and uploads.
1557
+ */
1558
+ export function serializeSnapshot(snapshot) {
1559
+ const lines = ['{'];
1560
+ lines.push(` "schemaVersion": ${JSON.stringify(snapshot.schemaVersion)},`);
1561
+ lines.push(` "projection": ${JSON.stringify({ version: snapshot.projection.version, ipPseudonymization: snapshot.projection.ipPseudonymization })},`);
1562
+ lines.push(` "accountId": ${JSON.stringify(snapshot.accountId)},`);
1563
+ lines.push(` "collector": ${JSON.stringify(canonicalCollector(snapshot.collector))},`);
1564
+ const entryLines = snapshot.entries.map((entry) => ` ${entryJson(entry)}`);
1565
+ lines.push(entryLines.length === 0 ? ' "entries": [],' : ` "entries": [\n${entryLines.join(',\n')}\n ],`);
1566
+ const bodyLines = Object.entries(snapshot.ruleBodies).map(([ref, runs]) => ` ${JSON.stringify(ref)}: ${canonicalJson(runs)}`);
1567
+ lines.push(bodyLines.length === 0 ? ' "ruleBodies": {}' : ` "ruleBodies": {\n${bodyLines.join(',\n')}\n }`);
1568
+ lines.push('}');
1569
+ return `${lines.join('\n')}\n`;
1570
+ }
1571
+ function entryJson(entry) {
1572
+ const { kind, result: entryResult, ...ids } = canonicalEntry(entry);
1573
+ const head = [`"kind":${JSON.stringify(kind)}`];
1574
+ for (const [key, value] of Object.entries(ids))
1575
+ head.push(`${JSON.stringify(key)}:${JSON.stringify(value)}`);
1576
+ const r = entryResult;
1577
+ const resultJson = r.ok ? `{"ok":true,"data":${canonicalJson(r.data)}}` : JSON.stringify(r);
1578
+ return `{${head.join(',')},"result":${resultJson}}`;
1579
+ }
1580
+ // ============================================================================
1581
+ // HTTP contract (responses are non-strict so the server may add fields)
1582
+ // ============================================================================
1583
+ export const LOCAL_ERROR_REASONS = ['session_invalid', 'session_expired', 'session_used', 'session_busy', 'session_not_ready',
1584
+ 'run_closed', 'browser_origin', 'malformed_json', 'digest_mismatch', 'unsupported_media_type',
1585
+ 'length_required', 'snapshot_too_large', 'snapshot_invalid', 'snapshot_incomplete', 'account_mismatch', 'snapshot_stale',
1586
+ 'collector_unsupported', 'secret_like_value', 'evaluation_failed', 'rate_limited', 'local_mode_disabled', 'storage_unavailable',
1587
+ 'request_invalid', 'email_invalid', 'code_invalid', 'verification_invalid', 'verification_expired'];
1588
+ export const LOCAL_SESSION_STATES = ['awaiting_upload', 'processing', 'completed'];
1589
+ // `accountId` is null until an uploaded snapshot binds the run (the CLI chooses the account); runs created
1590
+ // with an account keep it, and a completed run reports the evaluated account.
1591
+ export const LocalSessionResponseSchema = z.object({ success: z.literal(true), session: z.object({ accountId: hex32.nullable(),
1592
+ requester: z.string().max(260), expiresAt: iso, state: z.enum(LOCAL_SESSION_STATES),
1593
+ accepts: z.object({ schemaVersions: z.array(z.string()).min(1), maxBytes: z.number().int().positive(),
1594
+ maxZones: z.number().int().positive() }), minCliVersion: semver }) });
1595
+ // `resultsUrl` is the run's results page; the CLI opens it only when it is on the upload origin.
1596
+ export const LocalSnapshotResponseSchema = z.object({ success: z.literal(true), replayed: z.boolean(),
1597
+ snapshotSha256: z.string().regex(/^[0-9a-f]{64}$/),
1598
+ provenance: z.record(z.string(), z.unknown()), report: z.record(z.string(), z.unknown()),
1599
+ resultsUrl: z.url().max(2048).optional() });
1600
+ // Email verification in the CLI. Request bodies are strict: the server refuses fields it does not know.
1601
+ export const LocalRequestBodySchema = z.object({ email: z.string().min(3).max(254), locale: z.enum(LOCAL_EMAIL_LOCALES) }).strict();
1602
+ export const LocalVerifyBodySchema = z.object({ verification: z.string().regex(VERIFICATION_TOKEN_REGEX),
1603
+ code: z.string().regex(EMAIL_CODE_REGEX) }).strict();
1604
+ // `expiresAt` is when the emailed code (and so the token) stops working.
1605
+ export const LocalRequestResponseSchema = z.object({ success: z.literal(true), verification: z.object({
1606
+ token: z.string().regex(VERIFICATION_TOKEN_REGEX), requester: z.string().max(260), expiresAt: iso
1607
+ }) });
1608
+ export const LocalVerifyResponseSchema = z.object({ success: z.literal(true), session: z.object({
1609
+ code: z.string().regex(SESSION_CODE_REGEX), requester: z.string().max(260), expiresAt: iso
1610
+ }) });
1611
+ export const LocalErrorResponseSchema = z.object({ success: z.literal(false), status: z.number().int(), code: z.string(),
1612
+ reason: z.enum(LOCAL_ERROR_REASONS).optional(), retryAfterSec: z.number().int().nonnegative().optional(),
1613
+ issues: z.array(z.object({ path: z.string().max(200), code: z.string().max(64) })).max(20).optional() });
1614
+ //# sourceMappingURL=contract.js.map