| 1 | // Read-only diagnostics. The token stays in the runner and is sent only to the |
| 2 | // Cloudflare API; reports omit visitor identities, response bodies, and rules. |
| 3 | import { createHash } from "node:crypto"; |
| 4 | |
| 5 | const token = process.env.CLOUDFLARE_API_TOKEN; |
| 6 | if (!token) throw new Error("CLOUDFLARE_API_TOKEN is not configured"); |
| 7 | const api = "https://api.cloudflare.com/client/v4"; |
| 8 | const report = (kind, data) => console.log(JSON.stringify({ kind, ...data })); |
| 9 | |
| 10 | function errorClass(error) { |
| 11 | const message = String(error.message || ""); |
| 12 | if (/cannot query field|unknown field|not defined by type|unknown argument/i.test(message)) return "query-schema"; |
| 13 | if (/permission|unauthori[sz]ed|access denied|authentication|forbidden/i.test(message)) return "permission"; |
| 14 | if (/cannot request data older|maximum.*(window|duration)|time range/i.test(message)) return "query-window"; |
| 15 | return "unknown"; |
| 16 | } |
| 17 | |
| 18 | async function request(endpoint, body) { |
| 19 | const response = await fetch(`${api}${endpoint}`, { |
| 20 | method: body ? "POST" : "GET", |
| 21 | headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, |
| 22 | ...(body ? { body: JSON.stringify(body) } : {}), |
| 23 | signal: AbortSignal.timeout(30000), |
| 24 | redirect: "error", |
| 25 | }); |
| 26 | const json = await response.json(); |
| 27 | if (!response.ok || json.success === false || json.errors?.length) { |
| 28 | // Error messages may contain visitor data. Publish only codes and classes. |
| 29 | report("api-unavailable", { endpoint, status: response.status, |
| 30 | errors: (json.errors || []).map(error => ({ code: Number.isInteger(error.code) ? error.code : null, class: errorClass(error) })) }); |
| 31 | return null; |
| 32 | } |
| 33 | return json; |
| 34 | } |
| 35 | |
| 36 | for (const [profile, userAgent] of [ |
| 37 | ["node-default", undefined], |
| 38 | ["desktop-updater-current", "Reasonix-Updater/v1.38.11 (linux/amd64; build=stable; update=stable)"], |
| 39 | ["desktop-updater-target", "Reasonix-Updater/v1.38.12 (linux/amd64; build=stable; update=stable)"], |
| 40 | ]) { |
| 41 | const probe = await fetch("https://dl.reasonix.io/latest/latest.json", { |
| 42 | headers: userAgent ? { "User-Agent": userAgent } : undefined, |
| 43 | signal: AbortSignal.timeout(30000), redirect: "error", |
| 44 | }); |
| 45 | report("public-manifest", { |
| 46 | profile, |
| 47 | status: probe.status, |
| 48 | mitigation: probe.headers.get("cf-mitigated"), |
| 49 | ray: probe.headers.get("cf-ray"), |
| 50 | contentType: probe.headers.get("content-type"), |
| 51 | }); |
| 52 | await probe.body?.cancel(); |
| 53 | } |
| 54 | |
| 55 | const zones = await request("/zones?name=reasonix.io&status=active"); |
| 56 | const matches = zones?.result?.filter(zone => zone.name === "reasonix.io"); |
| 57 | if (matches?.length !== 1) { |
| 58 | throw new Error("Cannot identify reasonix.io with this token; Zone Read permission is required"); |
| 59 | } |
| 60 | const zoneID = matches[0].id; |
| 61 | report("zone", { name: "reasonix.io", id: zoneID }); |
| 62 | |
| 63 | for (const phase of ["http_config_settings", "http_request_firewall_custom", "http_request_firewall_managed", "http_request_sbfm"]) { |
| 64 | const response = await request(`/zones/${zoneID}/rulesets/phases/${phase}/entrypoint`); |
| 65 | if (!response) continue; |
| 66 | const ruleset = response.result; |
| 67 | report("ruleset", { |
| 68 | phase, id: ruleset.id, |
| 69 | rules: (ruleset.rules || []).map(rule => ({ |
| 70 | id: rule.id, action: rule.action, enabled: rule.enabled !== false, |
| 71 | expressionSHA256: createHash("sha256").update(rule.expression || "").digest("hex"), |
| 72 | })), |
| 73 | }); |
| 74 | } |
| 75 | for (const setting of ["security_level", "browser_check"]) { |
| 76 | const response = await request(`/zones/${zoneID}/settings/${setting}`); |
| 77 | if (response) report("setting", { setting, value: response.result.value, editable: response.result.editable }); |
| 78 | } |
| 79 | const bots = await request(`/zones/${zoneID}/bot_management`); |
| 80 | if (bots) report("bot-management", { |
| 81 | fightMode: bots.result.fight_mode, |
| 82 | definitelyAutomated: bots.result.sbfm_definitely_automated, |
| 83 | likelyAutomated: bots.result.sbfm_likely_automated, |
| 84 | }); |
| 85 | |
| 86 | const events = await request("/graphql", { |
| 87 | query: `query ManifestChallenges($zoneTag: string, $filter: FirewallEventsAdaptiveFilter_InputObject) { |
| 88 | viewer { zones(filter: { zoneTag: $zoneTag }) { |
| 89 | firewallEventsAdaptive(filter: $filter, limit: 500, orderBy: [datetime_DESC]) { |
| 90 | action source datetime clientRequestHTTPHost clientRequestPath |
| 91 | } |
| 92 | } } |
| 93 | }`, |
| 94 | variables: { |
| 95 | zoneTag: zoneID, |
| 96 | filter: { |
| 97 | datetime_geq: new Date(Date.now() - 10 * 60 * 1000).toISOString(), |
| 98 | datetime_leq: new Date().toISOString(), |
| 99 | }, |
| 100 | }, |
| 101 | }); |
| 102 | if (events) { |
| 103 | const entries = events.data?.viewer?.zones?.[0]?.firewallEventsAdaptive ?? []; |
| 104 | const matching = entries.filter(event => event.clientRequestHTTPHost === "dl.reasonix.io" && event.clientRequestPath === "/latest/latest.json"); |
| 105 | report("manifest-security-events", { inspected: entries.length, |
| 106 | events: matching.map(({ action, source, datetime }) => ({ action, source, datetime })) }); |
| 107 | } |
| 108 |