@vigilbase/cli 0.1.4 → 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.
- package/CLOUDFLARE.md +2 -0
- package/COMMANDS.md +43 -6
- package/LOCAL.md +731 -0
- package/README.md +58 -6
- package/dist/auth/device-auth.d.ts.map +1 -1
- package/dist/auth/device-auth.js +7 -3
- package/dist/auth/device-auth.js.map +1 -1
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +2 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/provider.d.ts +18 -0
- package/dist/auth/provider.d.ts.map +1 -0
- package/dist/auth/provider.js +62 -0
- package/dist/auth/provider.js.map +1 -0
- package/dist/auth/refresh.d.ts +10 -0
- package/dist/auth/refresh.d.ts.map +1 -0
- package/dist/auth/refresh.js +143 -0
- package/dist/auth/refresh.js.map +1 -0
- package/dist/auth/store.d.ts +27 -2
- package/dist/auth/store.d.ts.map +1 -1
- package/dist/auth/store.js +23 -8
- package/dist/auth/store.js.map +1 -1
- package/dist/auth/token-metadata.d.ts +10 -0
- package/dist/auth/token-metadata.d.ts.map +1 -0
- package/dist/auth/token-metadata.js +22 -0
- package/dist/auth/token-metadata.js.map +1 -0
- package/dist/auth/types.d.ts +8 -1
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -9
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +20 -8
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/helpers.d.ts +15 -0
- package/dist/commands/helpers.d.ts.map +1 -1
- package/dist/commands/helpers.js +2 -2
- package/dist/commands/helpers.js.map +1 -1
- package/dist/commands/index.d.ts +3 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +6 -1
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/local.d.ts +30 -0
- package/dist/commands/local.d.ts.map +1 -0
- package/dist/commands/local.js +1520 -0
- package/dist/commands/local.js.map +1 -0
- package/dist/commands/public-tools.d.ts.map +1 -1
- package/dist/commands/public-tools.js +8 -4
- package/dist/commands/public-tools.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +16 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +36 -4
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/format.d.ts.map +1 -1
- package/dist/lib/format.js +9 -0
- package/dist/lib/format.js.map +1 -1
- package/dist/lib/help.d.ts +1 -1
- package/dist/lib/help.d.ts.map +1 -1
- package/dist/lib/help.js +9 -0
- package/dist/lib/help.js.map +1 -1
- package/dist/lib/network.d.ts.map +1 -1
- package/dist/lib/network.js +2 -1
- package/dist/lib/network.js.map +1 -1
- package/dist/lib/one-client.d.ts +3 -2
- package/dist/lib/one-client.d.ts.map +1 -1
- package/dist/lib/one-client.js +46 -2
- package/dist/lib/one-client.js.map +1 -1
- package/dist/lib/security-client.d.ts +1 -1
- package/dist/lib/tui.d.ts +309 -0
- package/dist/lib/tui.d.ts.map +1 -0
- package/dist/lib/tui.js +847 -0
- package/dist/lib/tui.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.d.ts +95 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.js +184 -0
- package/dist/local/cloudflare-checkup/cf/allowlist.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts +113 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js +313 -0
- package/dist/local/cloudflare-checkup/cf/auth-lifecycle.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/child-env.d.ts +32 -0
- package/dist/local/cloudflare-checkup/cf/child-env.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/child-env.js +81 -0
- package/dist/local/cloudflare-checkup/cf/child-env.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/install.d.ts +106 -0
- package/dist/local/cloudflare-checkup/cf/install.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/install.js +309 -0
- package/dist/local/cloudflare-checkup/cf/install.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/locate.d.ts +44 -0
- package/dist/local/cloudflare-checkup/cf/locate.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/locate.js +228 -0
- package/dist/local/cloudflare-checkup/cf/locate.js.map +1 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +89 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.js +275 -0
- package/dist/local/cloudflare-checkup/cf/run-cf.js.map +1 -0
- package/dist/local/cloudflare-checkup/collector.d.ts +48 -0
- package/dist/local/cloudflare-checkup/collector.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/collector.js +296 -0
- package/dist/local/cloudflare-checkup/collector.js.map +1 -0
- package/dist/local/cloudflare-checkup/contract.d.ts +2793 -0
- package/dist/local/cloudflare-checkup/contract.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/contract.js +1614 -0
- package/dist/local/cloudflare-checkup/contract.js.map +1 -0
- package/dist/local/cloudflare-checkup/email-verification.d.ts +125 -0
- package/dist/local/cloudflare-checkup/email-verification.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/email-verification.js +212 -0
- package/dist/local/cloudflare-checkup/email-verification.js.map +1 -0
- package/dist/local/cloudflare-checkup/errors.d.ts +28 -0
- package/dist/local/cloudflare-checkup/errors.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/errors.js +99 -0
- package/dist/local/cloudflare-checkup/errors.js.map +1 -0
- package/dist/local/cloudflare-checkup/output.d.ts +24 -0
- package/dist/local/cloudflare-checkup/output.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/output.js +89 -0
- package/dist/local/cloudflare-checkup/output.js.map +1 -0
- package/dist/local/cloudflare-checkup/prompts.d.ts +41 -0
- package/dist/local/cloudflare-checkup/prompts.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/prompts.js +140 -0
- package/dist/local/cloudflare-checkup/prompts.js.map +1 -0
- package/dist/local/cloudflare-checkup/rate-budget.d.ts +42 -0
- package/dist/local/cloudflare-checkup/rate-budget.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/rate-budget.js +80 -0
- package/dist/local/cloudflare-checkup/rate-budget.js.map +1 -0
- package/dist/local/cloudflare-checkup/snapshot-file.d.ts +53 -0
- package/dist/local/cloudflare-checkup/snapshot-file.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/snapshot-file.js +186 -0
- package/dist/local/cloudflare-checkup/snapshot-file.js.map +1 -0
- package/dist/local/cloudflare-checkup/submit-client.d.ts +98 -0
- package/dist/local/cloudflare-checkup/submit-client.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/submit-client.js +349 -0
- package/dist/local/cloudflare-checkup/submit-client.js.map +1 -0
- package/dist/local/cloudflare-checkup/terminal-ui.d.ts +171 -0
- package/dist/local/cloudflare-checkup/terminal-ui.d.ts.map +1 -0
- package/dist/local/cloudflare-checkup/terminal-ui.js +404 -0
- package/dist/local/cloudflare-checkup/terminal-ui.js.map +1 -0
- 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
|