pi-cloudflare 0.6.6 → 0.6.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cloudflare",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "Cloudflare Agent Plugin and native Pi extension providing official skills and cf_-prefixed MCP tools.",
5
5
  "keywords": [
6
6
  "agent-plugin",
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.6",
4
+ "version": "0.6.7",
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
+ }