@vigilbase/cli 0.1.4 → 0.2.1

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