pi-cloudflare 0.6.6 → 0.6.8
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/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/cloudflare-hardening/SKILL.md +227 -0
- package/skills/cloudflare-hardening/references/api-reference.md +270 -0
- package/skills/cloudflare-hardening/references/paid-features.md +91 -0
- package/skills/cloudflare-hardening/scripts/audit.mjs +406 -0
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "pi-cloudflare",
|
|
4
|
-
"version": "0.6.
|
|
4
|
+
"version": "0.6.8",
|
|
5
5
|
"description": "Cloudflare skills and cf_-prefixed MCP tools, with a native Pi adapter and an Agent Plugins 1.0 portable runtime.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "0xPlayerOne",
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cloudflare-hardening
|
|
3
|
+
description: Audits and hardens every Cloudflare account and zone in a user's estate by adopting free-tier security, performance, and reliability features. Use when a user asks to audit, harden, secure, optimize, or "get the most out of" Cloudflare, mentions Security Center insights, free-tier features, DNSSEC, WAF, security headers, cache or compression rules, TLS settings, bot protection, or Turnstile, or wants an estate-wide review rather than a single-zone change.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Cloudflare hardening
|
|
7
|
+
|
|
8
|
+
Takes a user's whole Cloudflare estate — every account, every zone — and moves it
|
|
9
|
+
to a hardened, optimized baseline using features available on its plans.
|
|
10
|
+
|
|
11
|
+
Two properties matter more than speed:
|
|
12
|
+
|
|
13
|
+
- **Measure first.** The estate is the source of truth, not this document. Many
|
|
14
|
+
"gaps" are already handled by the origin, and several changes that look like
|
|
15
|
+
wins actively break things. Read the live state before writing anything.
|
|
16
|
+
- **Prefer reversible, low-blast-radius changes.** Settings and rules are easy to
|
|
17
|
+
revert; DNS proxying and SSL mode are not. Where a change is an architecture
|
|
18
|
+
decision, surface it for the user instead of making it.
|
|
19
|
+
|
|
20
|
+
## Before you start
|
|
21
|
+
|
|
22
|
+
**Token scope.** The audit needs read access across accounts and zones, plus write
|
|
23
|
+
access for whatever it changes. If calls fail with `403 Authentication error`,
|
|
24
|
+
the token is missing a permission group — see the `cloudflare-api-token` and
|
|
25
|
+
`cloudflare-token-scopes` skills for how to enumerate and extend groups. A JSON
|
|
26
|
+
`403` is a scope gap; an HTML `403` from a dashboard session is a WAF challenge,
|
|
27
|
+
not a permission problem.
|
|
28
|
+
|
|
29
|
+
**Confirm the blast radius.** The user may want one zone, one account, or
|
|
30
|
+
everything. Ask if it is not clear, and say what you are about to change before
|
|
31
|
+
changing it. Reversible settings changes are usually fine to batch; anything in
|
|
32
|
+
[Safety rules](#safety-rules-do-not-blanket-change) needs explicit sign-off.
|
|
33
|
+
|
|
34
|
+
## Ground rules
|
|
35
|
+
|
|
36
|
+
1. **Discover, never assume.** Enumerate accounts from `GET /accounts` and zones
|
|
37
|
+
from `GET /zones`. Never hard-code an account ID, zone ID, or domain — the
|
|
38
|
+
same skill has to run against estates the user has not described yet.
|
|
39
|
+
|
|
40
|
+
2. **Read the live response, not just the config.** A setting being `on` does not
|
|
41
|
+
mean the header is present: an unproxied record never executes zone rules, and
|
|
42
|
+
an origin can override what you set. Probe the real URL before and after.
|
|
43
|
+
|
|
44
|
+
3. **Never trust an HTTP 200.** Several Cloudflare endpoints return `200` and
|
|
45
|
+
change nothing. See [Silent no-ops](#silent-no-ops-http-200-with-no-effect).
|
|
46
|
+
After every write, re-read the resource and compare.
|
|
47
|
+
|
|
48
|
+
4. **Distinguish entitlement from permission.** When a call fails, replay it with
|
|
49
|
+
the user's dashboard session (or check the dashboard UI). If that also fails,
|
|
50
|
+
it is a plan gate, not a token-scope problem — do not add permission groups
|
|
51
|
+
chasing it.
|
|
52
|
+
|
|
53
|
+
5. **Allow time to propagate.** Rule and setting changes can read back stale for
|
|
54
|
+
a couple of minutes. Re-probe with a cache-busting query before concluding a
|
|
55
|
+
write failed.
|
|
56
|
+
|
|
57
|
+
## Workflow
|
|
58
|
+
|
|
59
|
+
### 1. Inventory (read-only)
|
|
60
|
+
|
|
61
|
+
Run the bundled audit script. It walks every account and zone and prints a gap
|
|
62
|
+
report without changing anything:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
CLOUDFLARE_API_TOKEN=... node scripts/audit.mjs # human summary
|
|
66
|
+
CLOUDFLARE_API_TOKEN=... node scripts/audit.mjs --json # machine-readable
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The script reports gaps; it deliberately does not fix them, because several
|
|
70
|
+
fixes need judgement from the live probe. Work from its output, and see
|
|
71
|
+
`references/api-reference.md` for the underlying calls if you need to go beyond
|
|
72
|
+
it.
|
|
73
|
+
|
|
74
|
+
### 2. Per zone: settings and rules
|
|
75
|
+
|
|
76
|
+
For each zone, read `GET /zones/:id/settings` and reconcile against this
|
|
77
|
+
baseline. Only change what differs, and note that the estate's current values may
|
|
78
|
+
be deliberate.
|
|
79
|
+
|
|
80
|
+
| Setting | Baseline | Notes |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| `min_tls_version` | `1.2` | `1.3` only if no legacy clients. `1.0`/`1.1` are deprecated and dropped by browsers |
|
|
83
|
+
| `always_use_https` | `on` | Redirects http to https |
|
|
84
|
+
| `automatic_https_rewrites` | `on` | |
|
|
85
|
+
| `tls_1_3` | `zrt` | `zrt` adds zero-round-trip resumption |
|
|
86
|
+
| `0rtt` | `on` | Skips a round trip on repeat visits. 0-RTT is replayable, so avoid it if the zone fronts non-idempotent writes on the same connection |
|
|
87
|
+
| `early_hints` | `on` | HTTP 103 preloads critical assets |
|
|
88
|
+
| `http2`, `http3`, `brotli`, `websockets`, `ipv6`, `opportunistic_encryption` | `on` | Usually already on |
|
|
89
|
+
| `security_level` | `medium` | Raise only during an active attack |
|
|
90
|
+
| `development_mode` | `off` | Never leave on; it disables caching and most optimizations |
|
|
91
|
+
|
|
92
|
+
Then check the ruleset phases and add what is missing:
|
|
93
|
+
|
|
94
|
+
- **`http_request_firewall_managed`** — deploy the **free managed ruleset**. This
|
|
95
|
+
is usually the single highest-value change, because the ruleset object already
|
|
96
|
+
exists on every zone but ships *unapplied*: there is no entrypoint until an
|
|
97
|
+
`execute` rule references it. A zone with no entrypoint has zero managed WAF
|
|
98
|
+
coverage. Payload in `references/api-reference.md`.
|
|
99
|
+
- **`http_response_headers_transform`** — add the baseline security headers the
|
|
100
|
+
live response is actually missing.
|
|
101
|
+
- **`http_response_compression`** — extend compression to binary model and font
|
|
102
|
+
types the built-in set omits.
|
|
103
|
+
- **`http_request_cache_settings`** — only with a measured reason; see
|
|
104
|
+
[Safety rules](#safety-rules-do-not-blanket-change).
|
|
105
|
+
|
|
106
|
+
### 3. Per zone: DNSSEC
|
|
107
|
+
|
|
108
|
+
Read `GET /zones/:id/dnssec`. If `disabled`, it is safe to stage: enabling is
|
|
109
|
+
**inert** until the registrar publishes the matching DS record, and an absent DS
|
|
110
|
+
cannot break validation. Confirm the parent has no DS first (`dig +short DS
|
|
111
|
+
<zone>`), then enable and hand the user the DS record.
|
|
112
|
+
|
|
113
|
+
Publishing the DS **cannot be automated**. Verify this rather than promising it:
|
|
114
|
+
Cloudflare's OpenAPI spec contains no registrar-side DS write, and
|
|
115
|
+
`PUT /accounts/:id/registrar/domains/:domain` accepts only `auto_renew`,
|
|
116
|
+
`locked`, and `privacy` — it returns `200` and ignores `ds_records`. Report which
|
|
117
|
+
domains are at which registrar, with the DS record for each, and let the user
|
|
118
|
+
finish it in the dashboard or at the registrar.
|
|
119
|
+
|
|
120
|
+
### 4. Per account
|
|
121
|
+
|
|
122
|
+
- **Security Center** (`GET /accounts/:id/security-center/insights`) — the
|
|
123
|
+
starting point for what the platform itself flags. Insights re-evaluate on
|
|
124
|
+
Cloudflare's schedule (roughly daily), so they will not clear immediately after
|
|
125
|
+
a fix. Some are non-actionable; see `references/paid-features.md`.
|
|
126
|
+
- **Zero Trust** — check existing Access apps have a policy, and that session
|
|
127
|
+
duration is sensible. Do not create apps unprompted; report.
|
|
128
|
+
- **Turnstile** — creating a widget is free. The **secret is returned only in the
|
|
129
|
+
create response** and never again, so capture it immediately and persist it
|
|
130
|
+
somewhere durable (see the Secrets Store notes in `references/api-reference.md`).
|
|
131
|
+
- **Secrets Store / Registrar / storage inventory** — useful context; report
|
|
132
|
+
rather than change.
|
|
133
|
+
|
|
134
|
+
### 5. Verify and report
|
|
135
|
+
|
|
136
|
+
Re-probe everything you changed, from the public internet:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
curl -sI https://<zone>/ | grep -iE "^(strict-transport-security|x-content-type-options|referrer-policy)"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Then confirm the zone still resolves and serves (`dig +short A`, HTTP status), and
|
|
143
|
+
close with the report format below.
|
|
144
|
+
|
|
145
|
+
## Silent no-ops: HTTP 200 with no effect
|
|
146
|
+
|
|
147
|
+
The most dangerous failure mode here, because the write looks successful. Every
|
|
148
|
+
item below was confirmed against the live API. Always re-read the resource after
|
|
149
|
+
writing and compare values.
|
|
150
|
+
|
|
151
|
+
| Operation | Behaviour |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| DNSSEC enable | `PATCH` with an empty body and `PUT` with an empty body both return `200` and leave the zone `disabled`. Use `PATCH {status:"active"}` (documented) or `PUT {status:"active"}`, then re-read to confirm it reached `pending` |
|
|
154
|
+
| Registrar domain update | `PUT` returns `200` for any body but applies only `auto_renew`, `locked`, `privacy`. Unknown fields, including `ds_records`, are silently dropped |
|
|
155
|
+
| `PATCH /zones/:id/settings/:setting` | Unknown or removed settings are ignored, and the response echoes the *unchanged* value. Compare the returned value to what you sent |
|
|
156
|
+
| Bot management | `PATCH` returns `405 10405 Method not allowed for this authentication scheme`; use `PUT`. The AI-bot fields live here, not under `/settings` |
|
|
157
|
+
|
|
158
|
+
Related method quirks worth knowing: some bulk endpoints take an **array** as the
|
|
159
|
+
request body and reject a single object with `1001 invalid_json_body`.
|
|
160
|
+
|
|
161
|
+
## Free vs paid
|
|
162
|
+
|
|
163
|
+
Most Cloudflare features have a generous free tier; a handful are paid and a few
|
|
164
|
+
carry a fixed monthly cost even at zero usage. Read
|
|
165
|
+
`references/paid-features.md` before promising anything, and apply this rule:
|
|
166
|
+
|
|
167
|
+
- **Free, or metered with no fixed cost** — adopt it.
|
|
168
|
+
- **Fixed monthly cost** — do not enable it unasked. Report it as available and
|
|
169
|
+
let the user decide.
|
|
170
|
+
|
|
171
|
+
Where a Security Center insight recommends something paid, say so plainly rather
|
|
172
|
+
than leaving the user to chase it. Those insights cannot be "fixed" on a free
|
|
173
|
+
plan.
|
|
174
|
+
|
|
175
|
+
## Safety rules (do not blanket-change)
|
|
176
|
+
|
|
177
|
+
These are the changes that look like hardening and are actually outages. Measure,
|
|
178
|
+
then decide — and get explicit sign-off where the change is not trivially
|
|
179
|
+
reversible.
|
|
180
|
+
|
|
181
|
+
- **HSTS.** `includeSubDomains` breaks every subdomain that is not HTTPS-capable,
|
|
182
|
+
and browsers cache it for `max_age`. A too-long `max_age`, or `preload`, is
|
|
183
|
+
effectively irreversible. Only enable `includeSubDomains` after confirming all
|
|
184
|
+
subdomains serve HTTPS, and never set `preload` casually.
|
|
185
|
+
- **`browser_cache_ttl` and cache rules.** Origins frequently send correct
|
|
186
|
+
`Cache-Control` already. Overriding it can serve stale HTML or one user's data
|
|
187
|
+
to another. Never blanket-cache HTML or API responses; scope rules to
|
|
188
|
+
content-hashed, immutable assets, and verify the origin's current header first.
|
|
189
|
+
- **SSL mode.** `full` → `full (strict)` requires a valid origin certificate.
|
|
190
|
+
Changing it without checking breaks the site.
|
|
191
|
+
- **Orange-cloud proxying.** Do not proxy records to clear a
|
|
192
|
+
`cname_record_not_proxied` insight. Third-party hosts frequently do not support
|
|
193
|
+
it (Shopify storefronts, some SaaS TLS flows), and proxying interacts with the
|
|
194
|
+
host's own certificate provisioning and HTTP-01 validation. Treat these as
|
|
195
|
+
intentional until the user decides.
|
|
196
|
+
- **Rocket Loader, Auto Minify, and other JS-rewriting features.** They can break
|
|
197
|
+
SPAs and modern bundlers. Enable only with a tested rollback path.
|
|
198
|
+
- **`security_level` → `under_attack`** is an emergency setting. Never leave it on.
|
|
199
|
+
|
|
200
|
+
Also worth checking before you trust a zone-level change: **is the hostname
|
|
201
|
+
actually proxied?** If the response has no `cf-ray` header, traffic reaches the
|
|
202
|
+
origin directly and no zone setting, rule, or transform applies — including
|
|
203
|
+
everything you just configured. Report this; it is often the reason "nothing
|
|
204
|
+
changed".
|
|
205
|
+
|
|
206
|
+
## Report format
|
|
207
|
+
|
|
208
|
+
Close with what changed, what was already fine, what is gated, and what needs the
|
|
209
|
+
user. Be concrete about evidence — a claim about a header or certificate should
|
|
210
|
+
come from a probe, not from the setting you just wrote.
|
|
211
|
+
|
|
212
|
+
```text
|
|
213
|
+
Summary: <what was audited, what was changed>
|
|
214
|
+
Changed: <per zone/account: setting or rule, old value -> new value>
|
|
215
|
+
Already optimal: <checked and left alone, with the measurement that justified it>
|
|
216
|
+
Gated by plan: <feature, tier required - not enabled>
|
|
217
|
+
Needs you: <DNS registrar steps, secrets to store, architecture decisions>
|
|
218
|
+
Verified: <probes run and their results>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## References
|
|
222
|
+
|
|
223
|
+
- `references/api-reference.md` — verified endpoint calls and payload shapes for
|
|
224
|
+
every change above.
|
|
225
|
+
- `references/paid-features.md` — free-tier limits, plan gates, and which
|
|
226
|
+
Security Center insights cannot be resolved for free.
|
|
227
|
+
- `scripts/audit.mjs` — read-only estate audit; run this first.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
Verified calls for each change the hardening skill makes. All paths are relative
|
|
4
|
+
to `https://api.cloudflare.com/client/v4`. Send
|
|
5
|
+
`Authorization: Bearer $CLOUDFLARE_API_TOKEN` and
|
|
6
|
+
`Content-Type: application/json`.
|
|
7
|
+
|
|
8
|
+
Read the current value before writing, and re-read after — see the silent-no-op
|
|
9
|
+
table in `SKILL.md`.
|
|
10
|
+
|
|
11
|
+
## Discovery
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
GET /accounts?per_page=50
|
|
15
|
+
GET /zones?per_page=50
|
|
16
|
+
GET /zones/:zone_id/settings
|
|
17
|
+
GET /zones/:zone_id/dns_records?per_page=100
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`GET /zones` returns each zone's `account`, `plan`, and `status`, so the whole
|
|
21
|
+
estate can be walked from these four calls.
|
|
22
|
+
|
|
23
|
+
To learn whether a hostname is proxied, look for the `cf-ray` response header on
|
|
24
|
+
a live request rather than trusting the DNS record alone.
|
|
25
|
+
|
|
26
|
+
## Zone settings
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
PATCH /zones/:zone_id/settings/:setting_id
|
|
30
|
+
{ "value": <value> }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
| Setting | Value | Notes |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `min_tls_version` | `"1.2"` | |
|
|
36
|
+
| `always_use_https` | `"on"` | |
|
|
37
|
+
| `automatic_https_rewrites` | `"on"` | |
|
|
38
|
+
| `tls_1_3` | `"zrt"` | |
|
|
39
|
+
| `0rtt` | `"on"` | |
|
|
40
|
+
| `early_hints` | `"on"` | |
|
|
41
|
+
| `brotli` | `"on"` | |
|
|
42
|
+
| `security_level` | `"medium"` | Raise only during an attack |
|
|
43
|
+
|
|
44
|
+
HSTS is a nested object:
|
|
45
|
+
|
|
46
|
+
```jsonc
|
|
47
|
+
PATCH /zones/:zone_id/settings/security_header
|
|
48
|
+
{
|
|
49
|
+
"value": {
|
|
50
|
+
"strict_transport_security": {
|
|
51
|
+
"enabled": true,
|
|
52
|
+
"max_age": 15552000, // 180 days
|
|
53
|
+
"include_subdomains": true, // only if every subdomain is HTTPS-capable
|
|
54
|
+
"preload": false, // effectively irreversible; leave false
|
|
55
|
+
"nosniff": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Some settings are read-only on a given plan and arrive with `"editable": false`.
|
|
62
|
+
Check that field before attempting a write; the PATCH will not tell you.
|
|
63
|
+
|
|
64
|
+
## Bot management
|
|
65
|
+
|
|
66
|
+
The AI-bot and fight-mode fields live here, not under `/settings`. Use `PUT` —
|
|
67
|
+
`PATCH` answers `405 10405`.
|
|
68
|
+
|
|
69
|
+
```jsonc
|
|
70
|
+
// read first, then write back the merged object
|
|
71
|
+
GET /zones/:zone_id/bot_management
|
|
72
|
+
|
|
73
|
+
PUT /zones/:zone_id/bot_management
|
|
74
|
+
{ "ai_bots_protection": "block" } // disabled | only_on_ad_pages | block
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Free managed WAF ruleset
|
|
78
|
+
|
|
79
|
+
The managed ruleset object **already exists** on every zone but is not applied —
|
|
80
|
+
there is no entrypoint until a rule executes it. A zone with no entrypoint has no
|
|
81
|
+
managed WAF coverage at all.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# 1. find the managed ruleset id for this zone
|
|
85
|
+
GET /zones/:zone_id/rulesets
|
|
86
|
+
# → look for phase "http_request_firewall_managed"
|
|
87
|
+
|
|
88
|
+
# 2. apply it
|
|
89
|
+
PUT /zones/:zone_id/rulesets/phases/http_request_firewall_managed/entrypoint
|
|
90
|
+
{
|
|
91
|
+
"rules": [
|
|
92
|
+
{
|
|
93
|
+
"action": "execute",
|
|
94
|
+
"description": "Deploy Cloudflare Free Managed Ruleset",
|
|
95
|
+
"enabled": true,
|
|
96
|
+
"expression": "true",
|
|
97
|
+
"action_parameters": { "id": "<managed ruleset id>" }
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Coverage is a fixed set of CVE signatures (Log4j, Shellshock, common WordPress
|
|
104
|
+
plugin and injection families) — not a general-purpose WAF. Verified behaviour:
|
|
105
|
+
|
|
106
|
+
| Probe | Result |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| `${jndi:…}` in a request **header** | `403` — blocked |
|
|
109
|
+
| Shellshock payload in `User-Agent` | `403` — blocked |
|
|
110
|
+
| `${jndi:…}` in the **URI path** | `307`/`308` — NOT blocked (those are path-normalization redirects) |
|
|
111
|
+
| SQLi / XSS payloads | Not covered |
|
|
112
|
+
|
|
113
|
+
Do not read a non-`403` on a URI payload as a broken deployment.
|
|
114
|
+
|
|
115
|
+
Read the ruleset with `GET /zones/:zone_id/rulesets/:ruleset_id` to inspect the
|
|
116
|
+
signature list on the user's plan.
|
|
117
|
+
|
|
118
|
+
## Response header transform rules
|
|
119
|
+
|
|
120
|
+
Preserve any rules already in the phase — read the entrypoint first, then PUT the
|
|
121
|
+
merged list. Strip server-assigned fields (`id`, `version`, `last_updated`,
|
|
122
|
+
`ref`) from rules you read back.
|
|
123
|
+
|
|
124
|
+
```jsonc
|
|
125
|
+
PUT /zones/:zone_id/rulesets/phases/http_response_headers_transform/entrypoint
|
|
126
|
+
{
|
|
127
|
+
"rules": [
|
|
128
|
+
{
|
|
129
|
+
"action": "rewrite",
|
|
130
|
+
"description": "Baseline security headers",
|
|
131
|
+
"enabled": true,
|
|
132
|
+
"expression": "true",
|
|
133
|
+
"action_parameters": {
|
|
134
|
+
"headers": {
|
|
135
|
+
"x-content-type-options": { "operation": "set", "value": "nosniff" },
|
|
136
|
+
"referrer-policy": { "operation": "set", "value": "strict-origin-when-cross-origin" },
|
|
137
|
+
"x-frame-options": { "operation": "set", "value": "SAMEORIGIN" },
|
|
138
|
+
"permissions-policy": { "operation": "set", "value": "camera=(), microphone=(), geolocation=()" }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Only add headers the live response is missing — an origin that already sets one
|
|
147
|
+
should keep its own value. Use `operation: "add"` for genuinely multi-value
|
|
148
|
+
headers so origin values are preserved.
|
|
149
|
+
|
|
150
|
+
Expression operators are plan-gated: `matches` (regex) needs Business or WAF
|
|
151
|
+
Advanced, while `in`, `eq`, `starts_with`, `extension`, `len`, and `substring`
|
|
152
|
+
work on Free. Match on file extension rather than hash-shaped paths.
|
|
153
|
+
|
|
154
|
+
A missing entrypoint answers `404` with error code `10003
|
|
155
|
+
could not find entrypoint ruleset in the <phase> phase` — which confirms the
|
|
156
|
+
permission is present, so treat it as "nothing configured yet", not a failure.
|
|
157
|
+
|
|
158
|
+
## Compression rules
|
|
159
|
+
|
|
160
|
+
Cloudflare's built-in compression covers js/css/json/svg/wasm but omits several
|
|
161
|
+
binary types that compress well:
|
|
162
|
+
|
|
163
|
+
```jsonc
|
|
164
|
+
PUT /zones/:zone_id/rulesets/phases/http_response_compression/entrypoint
|
|
165
|
+
{
|
|
166
|
+
"rules": [
|
|
167
|
+
{
|
|
168
|
+
"action": "compress_response",
|
|
169
|
+
"description": "Compress model and font assets",
|
|
170
|
+
"enabled": true,
|
|
171
|
+
"expression": "(http.request.uri.path.extension in {\"glb\" \"gltf\" \"bin\" \"woff\" \"woff2\" \"ttf\"})",
|
|
172
|
+
"action_parameters": {
|
|
173
|
+
"algorithms": [{ "name": "brotli" }, { "name": "gzip" }]
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Confirm the payload actually shrinks by requesting the asset with and without
|
|
181
|
+
`Accept-Encoding: br` and comparing `content-encoding` / `content-length`.
|
|
182
|
+
|
|
183
|
+
## DNSSEC
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
GET /zones/:zone_id/dnssec # status: disabled | pending | active
|
|
187
|
+
PATCH /zones/:zone_id/dnssec # documented path
|
|
188
|
+
{ "status": "active" }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`PUT` with an explicit `{"status":"active"}` body also works. Empty-body `PATCH`
|
|
192
|
+
and empty-body `PUT` both return `200` without enabling anything — re-read to
|
|
193
|
+
confirm the zone reached `pending`.
|
|
194
|
+
|
|
195
|
+
Enabling is safe and inert: resolution is unaffected until the registrar
|
|
196
|
+
publishes the DS. Check the parent first:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
dig +short DS <zone> # empty means no DS is published
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The response carries the record to hand the user:
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
<zone>. 3600 IN DS <key_tag> <algorithm> <digest_type> <digest>
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**There is no API to publish the DS.** Confirmed by searching Cloudflare's
|
|
209
|
+
published OpenAPI spec for `dnssec`/`ds_record` in every registrar section (no
|
|
210
|
+
matches) and by writing to the registrar endpoint, which returns `200` and
|
|
211
|
+
ignores the field. The user finishes this in the dashboard or at their registrar.
|
|
212
|
+
|
|
213
|
+
## Turnstile
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
GET /accounts/:account_id/challenges/widgets
|
|
217
|
+
POST /accounts/:account_id/challenges/widgets
|
|
218
|
+
{ "name": "<name>", "domains": ["<zone>"], "mode": "managed" }
|
|
219
|
+
# → result.sitekey and result.secret — the secret is shown ONCE
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The secret is never returned again. Store it immediately, ideally in the
|
|
223
|
+
account's Secrets Store:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
GET /accounts/:account_id/secrets_store/stores
|
|
227
|
+
# reuse an existing store if one exists — the Free plan allows only one
|
|
228
|
+
|
|
229
|
+
POST /accounts/:account_id/secrets_store/stores/:store_id/secrets
|
|
230
|
+
# body is an ARRAY; a bare object fails with 1001 invalid_json_body
|
|
231
|
+
[
|
|
232
|
+
{ "name": "TURNSTILE_SITEKEY", "value": "<sitekey>", "scopes": ["workers"] },
|
|
233
|
+
{ "name": "TURNSTILE_SECRET", "value": "<secret>", "scopes": ["workers"] }
|
|
234
|
+
]
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Stored values are write-only on read-back, so capture them from the create
|
|
238
|
+
response. `GET /accounts/:account_id/secrets_store/quota` shows usage.
|
|
239
|
+
|
|
240
|
+
## Registrar
|
|
241
|
+
|
|
242
|
+
Read-only in practice:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
GET /accounts/:account_id/registrar/domains?per_page=100
|
|
246
|
+
GET /accounts/:account_id/registrar/domains/:domain
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The `ds_records` array shows what is published at the registry (`[]` when
|
|
250
|
+
nothing is). `PUT` accepts only `auto_renew`, `locked`, and `privacy`.
|
|
251
|
+
|
|
252
|
+
## Verification probes
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
# security headers, cache directives, proxy status
|
|
256
|
+
curl -sI https://<zone>/ | grep -iE "^(strict-transport-security|referrer-policy|x-content-type-options|cf-ray|cache-control)"
|
|
257
|
+
|
|
258
|
+
# compression
|
|
259
|
+
curl -sI -H "Accept-Encoding: br" https://<zone>/<asset> | grep -iE "^(content-encoding|content-length)"
|
|
260
|
+
|
|
261
|
+
# WAF (expect 403 once the managed ruleset is applied)
|
|
262
|
+
curl -s -o /dev/null -w '%{http_code}\n' -H 'User-Agent: () { :;}; /bin/bash -c "echo pwned"' https://<zone>/
|
|
263
|
+
|
|
264
|
+
# resolution and DNSSEC health
|
|
265
|
+
dig +short A <zone> @1.1.1.1
|
|
266
|
+
dig <zone> @8.8.8.8 | grep -oE "status: [A-Z]+" # NOERROR = healthy; SERVFAIL = broken
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Responses can be cached briefly after a change — re-probe with a cache-busting
|
|
270
|
+
query string before concluding a write failed.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Plan gates and paid features
|
|
2
|
+
|
|
3
|
+
Cloudflare's free tiers are generous and most hardening items cost nothing. This
|
|
4
|
+
file records which features are gated, so the audit does not chase a plan limit
|
|
5
|
+
with permission changes.
|
|
6
|
+
|
|
7
|
+
**Numbers change.** Fetch current limits from
|
|
8
|
+
`https://developers.cloudflare.com/` before quoting a quota or a price. The
|
|
9
|
+
plan *gates* below are the durable part; the figures are not.
|
|
10
|
+
|
|
11
|
+
## Gated on the Free plan
|
|
12
|
+
|
|
13
|
+
Confirmed by replaying each call with a full dashboard session — if the session
|
|
14
|
+
also fails, it is entitlement rather than token scope. These return an
|
|
15
|
+
entitlement or quota message, so **adding permission groups will not help**.
|
|
16
|
+
|
|
17
|
+
| Feature | Signal | Notes |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| Argo Smart Routing | `401 1015` | Paid zone setting, fixed monthly cost |
|
|
20
|
+
| Polish / Mirage / WebP | `editable: false` on the setting | Paid image optimizations |
|
|
21
|
+
| Image Transformations / Images | `allowed: 0` on `/accounts/:id/images/v1/stats` | Needs a paid Images plan |
|
|
22
|
+
| Snippets | `403 snippets are not allowed` | Free-plan-disabled; rules out serving small responses like `security.txt` |
|
|
23
|
+
| Containers | `401` | Requires the Workers Paid plan |
|
|
24
|
+
| Workers for Platforms (dispatch) | `403 10121` | Paid/Enterprise |
|
|
25
|
+
| Spectrum | `403 10007` | Paid |
|
|
26
|
+
| Stream | `403` | Paid |
|
|
27
|
+
| Load Balancing, Health Checks, Waiting Rooms | Paid | Read access still works for inventory |
|
|
28
|
+
| Custom hostnames (SSL for SaaS) | `403 1404` | No quota allocated; Enterprise |
|
|
29
|
+
| Logpush jobs (account) | `401` without `Logs Write` | This one is a **scope** gap, not a plan gate — grant `Logs Write` and it works |
|
|
30
|
+
|
|
31
|
+
Two zone settings are not merely gated but **deprecated**:
|
|
32
|
+
|
|
33
|
+
- `waf` → `400 WAF is deprecated for this zone, Managed Rulesets should be used
|
|
34
|
+
instead.` Use the managed ruleset entrypoint instead.
|
|
35
|
+
- `minify` → accepts the PATCH and returns `200` with the value unchanged. Treat
|
|
36
|
+
auto-minify as unavailable and minify at build time.
|
|
37
|
+
|
|
38
|
+
## Free-tier features worth adopting
|
|
39
|
+
|
|
40
|
+
Everything here is free or metered with no fixed monthly cost, which makes it
|
|
41
|
+
safe to enable without a spending decision:
|
|
42
|
+
|
|
43
|
+
- TLS 1.2+ minimum, Always Use HTTPS, automatic HTTPS rewrites, TLS 1.3 with 0-RTT
|
|
44
|
+
- HTTP/2, HTTP/3, Brotli, Early Hints, WebSockets
|
|
45
|
+
- The **managed WAF ruleset** (a fixed CVE signature set)
|
|
46
|
+
- Custom WAF rules and rate limiting (metered)
|
|
47
|
+
- DNSSEC, HSTS, `security.txt` via a zone rule or the app
|
|
48
|
+
- Page Shield reporting, API discovery, bot fight mode, AI-bot blocking
|
|
49
|
+
- Cache rules, compression rules, response-header transform rules, origin rules,
|
|
50
|
+
config rules, single redirects, page rules
|
|
51
|
+
- Workers, KV, D1, R2 (with egress-free reads), Hyperdrive, Queues
|
|
52
|
+
- Zero Trust Access (free up to a seat cap), Cloudflare Tunnel
|
|
53
|
+
- Turnstile, Web Analytics, Workers AI, Vectorize, AI Gateway, Secrets Store
|
|
54
|
+
|
|
55
|
+
## Security Center insights that cannot be resolved for free
|
|
56
|
+
|
|
57
|
+
Security Center recommends things the current plan may not include. Report these
|
|
58
|
+
as gated rather than leaving the user to chase them, and note that dismissing an
|
|
59
|
+
insight is a legitimate way to clear it when the recommendation does not apply:
|
|
60
|
+
|
|
61
|
+
| Insight | Why it cannot be actioned |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `argo_smart_routing_not_enabled` | Paid feature with a fixed monthly cost |
|
|
64
|
+
| `polish_not_enabled` | Paid zone setting |
|
|
65
|
+
| `cname_record_not_proxied` | Usually intentional — see the proxying note below |
|
|
66
|
+
| `security_txt_not_enabled` | No public API, and Snippets are Free-plan-disabled. Needs a Workers route, a zone rule, or the app itself to serve the file |
|
|
67
|
+
|
|
68
|
+
Insights also re-evaluate on Cloudflare's schedule (roughly daily), so a correct
|
|
69
|
+
fix will not clear the insight immediately. Do not re-apply a change because the
|
|
70
|
+
insight is still listed.
|
|
71
|
+
|
|
72
|
+
## Two cost shapes
|
|
73
|
+
|
|
74
|
+
When judging whether to enable something, distinguish:
|
|
75
|
+
|
|
76
|
+
- **Metered, no fixed cost** — free until a usage threshold, then billed per unit.
|
|
77
|
+
Safe to enable; the failure mode is a surprise bill only at high volume.
|
|
78
|
+
- **Fixed monthly cost** — billed whether or not it is used (Argo, Polish,
|
|
79
|
+
Load Balancing, and similar). Do not enable these during an audit. Report them
|
|
80
|
+
as available and let the user make the call.
|
|
81
|
+
|
|
82
|
+
## Not a Cloudflare setting
|
|
83
|
+
|
|
84
|
+
Two common hardening items have no API and belong to the application or the
|
|
85
|
+
registrar:
|
|
86
|
+
|
|
87
|
+
- **`security.txt`** — serve it from the app's static assets or a Workers route.
|
|
88
|
+
There is no API for it (Security Center exposes only insights, classification,
|
|
89
|
+
severity, type, audit-log, and context endpoints).
|
|
90
|
+
- **DNSSEC DS records** — published at the registrar, not by API. See
|
|
91
|
+
`api-reference.md`.
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Read-only Cloudflare estate audit.
|
|
4
|
+
*
|
|
5
|
+
* Walks every account and zone the token can see and reports hardening gaps.
|
|
6
|
+
* Makes GET requests only — it never changes anything. Fixes need judgement
|
|
7
|
+
* from the live probe, so they stay with the agent (see ../SKILL.md).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* CLOUDFLARE_API_TOKEN=... node audit.mjs [options]
|
|
11
|
+
*
|
|
12
|
+
* Options:
|
|
13
|
+
* --json machine-readable output
|
|
14
|
+
* --no-probe skip live HTTP probes (faster, config-only)
|
|
15
|
+
* --account <id> limit to one account
|
|
16
|
+
* --zone <name> limit to one zone
|
|
17
|
+
* --help
|
|
18
|
+
*
|
|
19
|
+
* Exit codes: 0 = audit completed, 1 = no usable credential, 2 = fatal error.
|
|
20
|
+
* A finding does not fail the run — this is a report, not a gate.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
const BASE = 'https://api.cloudflare.com/client/v4'
|
|
27
|
+
const args = process.argv.slice(2)
|
|
28
|
+
const flag = (name) => args.includes(name)
|
|
29
|
+
const option = (name) => {
|
|
30
|
+
const i = args.indexOf(name)
|
|
31
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (flag('--help')) {
|
|
35
|
+
const source = readFileSync(new URL(import.meta.url), 'utf8')
|
|
36
|
+
const block = source.slice(source.indexOf('/**') + 3, source.indexOf('*/'))
|
|
37
|
+
console.log(
|
|
38
|
+
block
|
|
39
|
+
.split('\n')
|
|
40
|
+
.map((line) => line.replace(/^\s*\* ?/, ''))
|
|
41
|
+
.join('\n')
|
|
42
|
+
.trim()
|
|
43
|
+
)
|
|
44
|
+
process.exit(0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const AS_JSON = flag('--json')
|
|
48
|
+
const PROBE = !flag('--no-probe')
|
|
49
|
+
const ONLY_ACCOUNT = option('--account')
|
|
50
|
+
const ONLY_ZONE = option('--zone')
|
|
51
|
+
|
|
52
|
+
/** Resolve the token the same way the extension does: env, then the native Pi file. */
|
|
53
|
+
function resolveToken() {
|
|
54
|
+
if (process.env.CLOUDFLARE_API_TOKEN?.trim()) return process.env.CLOUDFLARE_API_TOKEN.trim()
|
|
55
|
+
try {
|
|
56
|
+
const contents = readFileSync(join(homedir(), '.pi', 'cloudflare-api-token'), 'utf8')
|
|
57
|
+
const match =
|
|
58
|
+
/^\s*export\s+CLOUDFLARE_API_TOKEN=(?:"([^"\r\n]*)"|'([^'\r\n]*)'|([^\s#\r\n]+))\s*$/m.exec(contents)
|
|
59
|
+
return match?.[1] || match?.[2] || match?.[3] || undefined
|
|
60
|
+
} catch {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const token = resolveToken()
|
|
66
|
+
if (!token) {
|
|
67
|
+
console.error('No credential found. Set CLOUDFLARE_API_TOKEN or create ~/.pi/cloudflare-api-token.')
|
|
68
|
+
console.error('See the cloudflare-api-token skill for how to mint one.')
|
|
69
|
+
process.exit(1)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const HEADERS = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
|
73
|
+
|
|
74
|
+
/** GET a path, returning { status, result, error }. Never throws on HTTP errors. */
|
|
75
|
+
async function get(path) {
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(`${BASE}${path}`, { headers: HEADERS })
|
|
78
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
79
|
+
if (!contentType.includes('json')) return { status: response.status, error: `non-JSON response (likely a WAF challenge)` }
|
|
80
|
+
const body = await response.json().catch(() => null)
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const err = body?.errors?.[0]
|
|
83
|
+
return { status: response.status, error: `${err?.code ?? ''} ${err?.message ?? ''}`.trim() }
|
|
84
|
+
}
|
|
85
|
+
return { status: response.status, result: body?.result }
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return { status: 0, error: error.message }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Settings that harden a zone, with the target value and why.
|
|
92
|
+
const SETTING_BASELINE = {
|
|
93
|
+
min_tls_version: { target: '1.2', why: 'TLS 1.0/1.1 are deprecated and dropped by browsers' },
|
|
94
|
+
always_use_https: { target: 'on', why: 'redirect http to https' },
|
|
95
|
+
automatic_https_rewrites: { target: 'on', why: 'rewrite mixed-content links' },
|
|
96
|
+
tls_1_3: { target: 'zrt', why: 'TLS 1.3 with zero-round-trip resumption' },
|
|
97
|
+
'0rtt': { target: 'on', why: 'skip a round trip on repeat visits' },
|
|
98
|
+
early_hints: { target: 'on', why: 'HTTP 103 preloads critical assets' },
|
|
99
|
+
brotli: { target: 'on', why: 'compression' },
|
|
100
|
+
http2: { target: 'on', why: 'protocol' },
|
|
101
|
+
http3: { target: 'on', why: 'protocol' },
|
|
102
|
+
websockets: { target: 'on', why: 'protocol' },
|
|
103
|
+
ipv6: { target: 'on', why: 'protocol' },
|
|
104
|
+
development_mode: { target: 'off', why: 'disables caching and optimization; must never be left on' },
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Settings whose current value is the user's call, reported but not flagged.
|
|
108
|
+
const SETTING_BASELINE_INFO = {
|
|
109
|
+
security_level: { target: 'medium', why: 'raise only during an attack' },
|
|
110
|
+
rocket_loader: { target: 'off', why: 'rewrites JS; can break SPAs' },
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const RULESET_PHASES = [
|
|
114
|
+
'http_request_firewall_managed',
|
|
115
|
+
'http_response_headers_transform',
|
|
116
|
+
'http_response_compression',
|
|
117
|
+
'http_request_cache_settings',
|
|
118
|
+
'http_request_dynamic_redirect',
|
|
119
|
+
'http_request_origin',
|
|
120
|
+
'http_config_settings',
|
|
121
|
+
'http_request_firewall_custom',
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
// Headers we expect on a hardened content response. HSTS is handled separately
|
|
125
|
+
// because enabling includeSubDomains needs care and sign-off.
|
|
126
|
+
const EXPECTED_HEADERS = ['x-content-type-options', 'referrer-policy']
|
|
127
|
+
|
|
128
|
+
const findings = []
|
|
129
|
+
const add = (scope, severity, subject, message, action) =>
|
|
130
|
+
findings.push({ scope, severity, subject, message, action })
|
|
131
|
+
|
|
132
|
+
async function probeHost(hostname) {
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetch(`https://${hostname}/`, {
|
|
135
|
+
redirect: 'manual',
|
|
136
|
+
signal: AbortSignal.timeout(15000),
|
|
137
|
+
})
|
|
138
|
+
const headers = {}
|
|
139
|
+
for (const name of [...EXPECTED_HEADERS, 'strict-transport-security', 'cf-ray']) {
|
|
140
|
+
headers[name] = response.headers.get(name)
|
|
141
|
+
}
|
|
142
|
+
return { status: response.status, headers }
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return { status: 0, error: error.message }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function auditZone(zone) {
|
|
149
|
+
const zoneFindingsStart = findings.length
|
|
150
|
+
const settingsRes = await get(`/zones/${zone.id}/settings`)
|
|
151
|
+
const settings = {}
|
|
152
|
+
for (const item of settingsRes.result ?? []) {
|
|
153
|
+
settings[item.id] = { value: item.value, editable: item.editable }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (settingsRes.error) {
|
|
157
|
+
add(zone.name, 'blocked', 'settings', `could not read zone settings: ${settingsRes.error}`, 'check token scope')
|
|
158
|
+
} else {
|
|
159
|
+
for (const [id, { target, why }] of Object.entries(SETTING_BASELINE)) {
|
|
160
|
+
const current = settings[id]
|
|
161
|
+
if (!current) continue
|
|
162
|
+
const normalised = typeof target === 'string' ? String(current.value) : current.value
|
|
163
|
+
if (normalised === target) continue
|
|
164
|
+
if (current.editable === false) {
|
|
165
|
+
add(zone.name, 'gated', id, `${current.value} (target ${target}) — read-only on this plan`, why)
|
|
166
|
+
} else {
|
|
167
|
+
add(zone.name, 'high', id, `${current.value} → ${target}`, why)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const [id, { target, why }] of Object.entries(SETTING_BASELINE_INFO)) {
|
|
171
|
+
const current = settings[id]
|
|
172
|
+
if (!current || String(current.value) === target) continue
|
|
173
|
+
add(zone.name, 'info', id, `currently ${current.value}`, why)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Ruleset phases: distinguish "nothing configured" from "cannot read".
|
|
178
|
+
const phases = {}
|
|
179
|
+
for (const phase of RULESET_PHASES) {
|
|
180
|
+
const res = await get(`/zones/${zone.id}/rulesets/phases/${phase}/entrypoint`)
|
|
181
|
+
const code = res.error?.split(' ')[0]
|
|
182
|
+
phases[phase] = res.status === 200 ? ((res.result?.rules?.length ?? 0) > 0 ? 'configured' : 'empty') : code === '10003' ? 'none' : `err:${res.error}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (phases.http_request_firewall_managed === 'none' || phases.http_request_firewall_managed === 'empty') {
|
|
186
|
+
add(
|
|
187
|
+
zone.name,
|
|
188
|
+
'high',
|
|
189
|
+
'managed WAF ruleset',
|
|
190
|
+
'free managed ruleset not applied — zone has no managed WAF coverage',
|
|
191
|
+
'PUT the http_request_firewall_managed entrypoint with an execute rule'
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
for (const phase of ['http_response_headers_transform', 'http_response_compression']) {
|
|
195
|
+
if (phases[phase] === 'none') {
|
|
196
|
+
add(zone.name, 'info', phase, 'not configured', 'add rules if the live probe shows a gap')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// DNSSEC.
|
|
201
|
+
const dnssec = await get(`/zones/${zone.id}/dnssec`)
|
|
202
|
+
const dnssecStatus = dnssec.result?.status
|
|
203
|
+
if (dnssecStatus === 'disabled') {
|
|
204
|
+
add(zone.name, 'high', 'dnssec', 'disabled', 'enable (inert until the registrar publishes the DS), then hand over the DS record')
|
|
205
|
+
} else if (dnssecStatus === 'pending') {
|
|
206
|
+
add(zone.name, 'medium', 'dnssec', 'pending — registrar DS not published', `publish: ${dnssec.result?.ds ?? 'read DS from the API'}`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Bot management (AI bots / fight mode live here, not under /settings).
|
|
210
|
+
const bot = await get(`/zones/${zone.id}/bot_management`)
|
|
211
|
+
if (bot.result) {
|
|
212
|
+
if (bot.result.ai_bots_protection && bot.result.ai_bots_protection !== 'block') {
|
|
213
|
+
add(zone.name, 'info', 'ai_bots_protection', `currently ${bot.result.ai_bots_protection}`, 'consider "block" (PUT, not PATCH)')
|
|
214
|
+
}
|
|
215
|
+
if (bot.result.fight_mode === false) {
|
|
216
|
+
add(zone.name, 'info', 'bot fight mode', 'disabled', 'consider enabling')
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// DNS insight into proxying — the usual reason zone rules appear to do nothing.
|
|
221
|
+
const records = await get(`/zones/${zone.id}/dns_records?per_page=100`)
|
|
222
|
+
let proxied = 0
|
|
223
|
+
let unproxied = 0
|
|
224
|
+
if (Array.isArray(records.result)) {
|
|
225
|
+
for (const record of records.result) {
|
|
226
|
+
if (['TXT', 'MX', 'NS', 'SRV', 'CAA'].includes(record.type)) continue
|
|
227
|
+
if (record.proxied) proxied += 1
|
|
228
|
+
else unproxied += 1
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const zoneResult = { zone: zone.name, account: zone.account?.name, plan: zone.plan?.name, settings, phases, dnssec: dnssecStatus, proxied, unproxied }
|
|
233
|
+
|
|
234
|
+
if (PROBE) {
|
|
235
|
+
const live = await probeHost(zone.name)
|
|
236
|
+
zoneResult.live = live
|
|
237
|
+
if (live.status === 0) {
|
|
238
|
+
add(zone.name, 'info', 'live probe', `could not reach the zone: ${live.error}`, 'verify the hostname serves traffic')
|
|
239
|
+
} else if (!live.headers['cf-ray']) {
|
|
240
|
+
add(
|
|
241
|
+
zone.name,
|
|
242
|
+
'high',
|
|
243
|
+
'not proxied',
|
|
244
|
+
'apex response has no cf-ray — traffic bypasses Cloudflare, so NO zone setting, rule, or transform applies',
|
|
245
|
+
'report to the user as an architecture decision; do not proxy third-party hosts to clear it'
|
|
246
|
+
)
|
|
247
|
+
} else if (live.status !== 200) {
|
|
248
|
+
// A redirect or error page has no content body to protect, so missing
|
|
249
|
+
// response headers here are not a real gap. Only note it.
|
|
250
|
+
add(zone.name, 'info', 'no content served', `HTTP ${live.status} at the apex — header checks skipped`, 'check a content hostname')
|
|
251
|
+
} else {
|
|
252
|
+
for (const header of EXPECTED_HEADERS) {
|
|
253
|
+
if (!live.headers[header]) {
|
|
254
|
+
add(zone.name, 'medium', header, 'absent on the live response', 'add via a response-header transform rule if the origin does not set it')
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (!live.headers['strict-transport-security']) {
|
|
258
|
+
add(zone.name, 'info', 'hsts', 'not served', 'enabling includeSubDomains requires every subdomain to be HTTPS-capable; get sign-off')
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
zoneResult.findings = findings.slice(zoneFindingsStart)
|
|
264
|
+
return zoneResult
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function auditAccount(account) {
|
|
268
|
+
const accountStart = findings.length
|
|
269
|
+
const summary = { account: account.name, id: account.id, plan: account.plan?.name }
|
|
270
|
+
|
|
271
|
+
const quotas = await get(`/accounts/${account.id}/secrets_store/quota`)
|
|
272
|
+
if (quotas.error) {
|
|
273
|
+
add(account.name, 'blocked', 'secrets store', quotas.error, 'grant Secrets Store read to inventory')
|
|
274
|
+
} else {
|
|
275
|
+
summary.secretsStore = quotas.result
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const widgets = await get(`/accounts/${account.id}/challenges/widgets`)
|
|
279
|
+
if (widgets.error) {
|
|
280
|
+
add(account.name, 'blocked', 'turnstile', widgets.error, 'grant Turnstile read to inventory')
|
|
281
|
+
} else {
|
|
282
|
+
summary.turnstileWidgets = (widgets.result ?? []).map((w) => w.name)
|
|
283
|
+
if ((widgets.result ?? []).length === 0) {
|
|
284
|
+
add(account.name, 'info', 'turnstile', 'no widgets', 'free to create; the secret is returned once')
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const apps = await get(`/accounts/${account.id}/access/apps`)
|
|
289
|
+
if (apps.error) {
|
|
290
|
+
add(account.name, 'blocked', 'zero trust', apps.error, 'grant Access read to inventory')
|
|
291
|
+
} else {
|
|
292
|
+
summary.accessApps = (apps.result ?? []).length
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const insights = await get(`/accounts/${account.id}/security-center/insights?per_page=100`)
|
|
296
|
+
if (insights.error) {
|
|
297
|
+
add(account.name, 'blocked', 'security center', insights.error, 'grant Security Center Insights read')
|
|
298
|
+
} else {
|
|
299
|
+
const issues = insights.result?.issues ?? []
|
|
300
|
+
summary.insights = issues.map((i) => ({ class: i.issue_class, severity: i.severity, subject: i.subject }))
|
|
301
|
+
for (const issue of issues) {
|
|
302
|
+
add(account.name, 'info', issue.issue_class, `${issue.severity} — ${issue.subject}`, 'see references/paid-features.md for gated items')
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const domains = await get(`/accounts/${account.id}/registrar/domains?per_page=100`)
|
|
307
|
+
summary.registrarDomains = domains.error
|
|
308
|
+
? { error: domains.error }
|
|
309
|
+
: (domains.result ?? []).map((d) => ({ name: d.name, dsPublished: (d.ds_records ?? []).length > 0 }))
|
|
310
|
+
|
|
311
|
+
summary.findings = findings.slice(accountStart)
|
|
312
|
+
return summary
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- main ---
|
|
316
|
+
|
|
317
|
+
const accountsRes = await get('/accounts?per_page=50')
|
|
318
|
+
if (accountsRes.error) {
|
|
319
|
+
console.error(`Could not list accounts: ${accountsRes.error}`)
|
|
320
|
+
console.error('The token needs account read access. See the cloudflare-api-token skill.')
|
|
321
|
+
process.exit(2)
|
|
322
|
+
}
|
|
323
|
+
let accounts = accountsRes.result ?? []
|
|
324
|
+
if (ONLY_ACCOUNT) accounts = accounts.filter((a) => a.id === ONLY_ACCOUNT || a.name === ONLY_ACCOUNT)
|
|
325
|
+
|
|
326
|
+
const zonesRes = await get('/zones?per_page=50')
|
|
327
|
+
if (zonesRes.error) {
|
|
328
|
+
console.error(`Could not list zones: ${zonesRes.error}`)
|
|
329
|
+
process.exit(2)
|
|
330
|
+
}
|
|
331
|
+
let zones = zonesRes.result ?? []
|
|
332
|
+
if (ONLY_ZONE) zones = zones.filter((z) => z.name === ONLY_ZONE)
|
|
333
|
+
if (ONLY_ACCOUNT) {
|
|
334
|
+
const ids = new Set(accounts.map((a) => a.id))
|
|
335
|
+
zones = zones.filter((z) => ids.has(z.account?.id))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const auditedZones = []
|
|
339
|
+
for (const zone of zones) auditedZones.push(await auditZone(zone))
|
|
340
|
+
|
|
341
|
+
const auditedAccounts = []
|
|
342
|
+
for (const account of accounts) auditedAccounts.push(await auditAccount(account))
|
|
343
|
+
|
|
344
|
+
const report = {
|
|
345
|
+
generatedAt: new Date().toISOString(),
|
|
346
|
+
accounts: auditedAccounts,
|
|
347
|
+
zones: auditedZones,
|
|
348
|
+
findings,
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (AS_JSON) {
|
|
352
|
+
console.log(JSON.stringify(report, null, 2))
|
|
353
|
+
} else {
|
|
354
|
+
const order = { high: 0, medium: 1, gated: 2, blocked: 3, info: 4 }
|
|
355
|
+
const sorted = [...findings].sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9))
|
|
356
|
+
|
|
357
|
+
console.log(`Cloudflare estate audit — ${accounts.length} account(s), ${zones.length} zone(s)`)
|
|
358
|
+
console.log(`Plans: ${[...new Set(zones.map((z) => z.plan?.name))].join(', ') || 'unknown'}\n`)
|
|
359
|
+
|
|
360
|
+
const groups = { high: [], medium: [], gated: [], blocked: [], info: [] }
|
|
361
|
+
for (const finding of sorted) (groups[finding.severity] ?? groups.info).push(finding)
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Collapse findings that repeat across many zones into one line, so a report
|
|
365
|
+
* about a large estate stays readable. A distinct message still gets its own
|
|
366
|
+
* line — only exact (subject, message) repeats are aggregated.
|
|
367
|
+
*/
|
|
368
|
+
const emit = (label, list) => {
|
|
369
|
+
if (list.length === 0) return
|
|
370
|
+
const collapsed = new Map()
|
|
371
|
+
for (const finding of list) {
|
|
372
|
+
const key = `${finding.subject}\u0000${finding.message}\u0000${finding.action ?? ''}`
|
|
373
|
+
const entry = collapsed.get(key)
|
|
374
|
+
if (entry) {
|
|
375
|
+
entry.scopes.push(finding.scope)
|
|
376
|
+
} else {
|
|
377
|
+
collapsed.set(key, { ...finding, scopes: [finding.scope] })
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
console.log(`${label} (${list.length} across ${new Set(list.map((f) => f.scope)).size} subject(s))`)
|
|
382
|
+
for (const entry of collapsed.values()) {
|
|
383
|
+
if (entry.scopes.length === 1) {
|
|
384
|
+
console.log(` ${entry.scopes[0].padEnd(24)} ${entry.subject.padEnd(32)} ${entry.message}`)
|
|
385
|
+
} else {
|
|
386
|
+
const names = entry.scopes.join(', ')
|
|
387
|
+
const shown = names.length > 90 ? `${names.slice(0, 87)}...` : names
|
|
388
|
+
console.log(` ${`${entry.scopes.length} zones`.padEnd(24)} ${entry.subject.padEnd(32)} ${entry.message}`)
|
|
389
|
+
console.log(` ${''.padEnd(24)} ${shown}`)
|
|
390
|
+
}
|
|
391
|
+
if (entry.action && entry.severity !== 'info') console.log(` ${''.padEnd(24)} → ${entry.action}`)
|
|
392
|
+
}
|
|
393
|
+
console.log('')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
emit('ACTION NEEDED', groups.high)
|
|
397
|
+
emit('WORTH REVIEWING', groups.medium)
|
|
398
|
+
emit('GATED BY PLAN', groups.gated)
|
|
399
|
+
emit('COULD NOT READ (token scope?)', groups.blocked)
|
|
400
|
+
emit('CONTEXT', groups.info)
|
|
401
|
+
|
|
402
|
+
const high = groups.high.length
|
|
403
|
+
console.log(high === 0 ? 'No high-severity gaps found.' : `${high} high-severity gap(s) to address.`)
|
|
404
|
+
console.log('\nThis audit changed nothing. Read ../SKILL.md before applying fixes —')
|
|
405
|
+
console.log('several endpoints return HTTP 200 without applying the change.')
|
|
406
|
+
}
|