# Recipe — Cloudflare traffic triage ("whose traffic is this?")

> **Diagnostic runbook**, ephemera-shaped: runnable Markdown, the agent is the runtime, the cloud is
> the source of truth. Unlike a stack plan there is **nothing to create or tear down** — every step is
> ✔ read-only, so this file has no Live State and no teardown movement. The only mutating options live
> in the Response Playbook at the end, each behind a gate.
>
> Applicable verbs: **triage** / **verify** (the same thing here). `apply` / `teardown` do not exist.
>
> Contributed from a live triage of a production Cloudflare zone ("why is one country 2× the US?"),
> genericized through the intake gate ([`CONTRIBUTING.md`](../../CONTRIBUTING.md)). Every gotcha below
> was hit in the wild on a **Free**-plan zone.

## Intent

A country, ASN, or user-agent is showing anomalous volume on a Cloudflare zone ("lots of traffic from
X") and the human wants attribution: **who** is it, **what** are they touching, **since when**, and
**does it matter**. Answer from the zone's own analytics API — no dashboard clicking, no log exports,
no reasoning over raw logs. Output is a machine-readable findings block (fleet interop: JSON first,
prose second).

The default answer is very often **"no incident"** — and reaching that conclusion *with evidence*, fast,
is the point. Anomalous volume is usually a scanner talking to itself, not a breach.

## Inputs

| Question | Options | Default | Sets |
|---|---|---|---|
| Which zone? | free-text FQDN | — | `ZONE_NAME` |
| Coarse window? | days back | `7` | `DAYS` |
| Drill-down window? | ≤ 1 day (free-plan hard limit) | last 24 h | `T0` / `T1` |

```
Legend  ✔ verify (read-only) · 🟡 config change (human aware) · 🔴 GATE (human go)
```

**Composes with:** [`web.cloudflare.md`](../../web.cloudflare.md) (whose `BOT_PROTECTION` knob is the
lever the playbook reaches for) and [`releases-page-clerk.md`](./releases-page-clerk.md) (whose agent
surface is the thing bot mitigation must not swat — see the 🔴 item in the playbook).

## Steps

### 0 · ✔ Preflight — token present, never printed

Credential discipline: the token lives in the environment (or the OS keychain — **ask the human before
pulling from a keychain**). Probe presence only; never echo, `grep` for, or interpolate the value into
visible output.

```sh
[ -n "$CLOUDFLARE_API_TOKEN" ] && echo token-present || echo token-MISSING
```

Needs scopes: **Zone Read**, **Analytics Read**. The bot-management read in step 4 rides on Zone Read.

### 1 · ✔ Resolve zone ID and plan

```sh
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones?name=$ZONE_NAME" | \
  python3 -c "import json,sys; z=json.load(sys.stdin)['result'][0]; print(z['id'], z['plan']['name'])"
```

Record `ZONE_ID`. **The plan name matters** — `Free` changes what steps 3–4 may query (see Gotchas).
Read it now rather than discovering the limit as a confusing API error three steps later.

### 2 · ✔ Coarse shape — daily totals + country ranking (multi-day works here)

`httpRequests1dGroups` accepts multi-day ranges on **every** plan; country data rides in
`sum.countryMap`. This is the query that still works when the adaptive dataset refuses.

```sh
python3 - <<'EOF'
import json, os, urllib.request, datetime
TOKEN=os.environ["CLOUDFLARE_API_TOKEN"]; ZONE=os.environ["ZONE_ID"]; DAYS=int(os.environ.get("DAYS","7"))
def gql(q,v):
    r=urllib.request.Request("https://api.cloudflare.com/client/v4/graphql",
        data=json.dumps({"query":q,"variables":v}).encode(),
        headers={"Authorization":f"Bearer {TOKEN}","Content-Type":"application/json"})
    return json.load(urllib.request.urlopen(r))
end=datetime.date.today(); start=end-datetime.timedelta(days=DAYS)
q="""query($zone:String!,$start:Date!,$end:Date!){viewer{zones(filter:{zoneTag:$zone}){
  httpRequests1dGroups(limit:40,filter:{date_geq:$start,date_leq:$end},orderBy:[date_ASC]){
    dimensions{date} sum{requests countryMap{clientCountryName requests threats}}}}}}"""
r=gql(q,{"zone":ZONE,"start":str(start),"end":str(end)})
if r.get("errors"): print("ERRORS:",json.dumps(r["errors"])[:600]); raise SystemExit
tot={}
for d in r["data"]["viewer"]["zones"][0]["httpRequests1dGroups"]:
    print(d["dimensions"]["date"],"total:",d["sum"]["requests"])
    for c in d["sum"]["countryMap"]: tot[c["clientCountryName"]]=tot.get(c["clientCountryName"],0)+c["requests"]
print("\ncountry totals:")
for k,v in sorted(tot.items(),key=lambda x:-x[1])[:12]: print(f"  {k:4} {v}")
EOF
```

Read **two** things:

1. **The true top countries** — the one the human noticed may not be #1. Dashboard glances are anchored
   on whatever the map happened to highlight; the ranking is the correction.
2. **Step changes in the daily totals** — a volume doubling on a *specific date* usually correlates with
   a config change (bot protection toggled, a new route shipped, a campaign sent), not an attack. Note
   the date; you will cross-check it in step 4.

### 3 · ✔ Drill down each suspect country — UA + path (≤ 1 day per query)

```sh
python3 - <<'EOF'
import json, os, urllib.request
TOKEN=os.environ["CLOUDFLARE_API_TOKEN"]; ZONE=os.environ["ZONE_ID"]
T0=os.environ["T0"]; T1=os.environ["T1"]   # e.g. 2026-01-14T00:00:00Z / 2026-01-14T23:59:59Z
CC=os.environ["CC"]                        # suspect country code, e.g. FR
def gql(q,v):
    r=urllib.request.Request("https://api.cloudflare.com/client/v4/graphql",
        data=json.dumps({"query":q,"variables":v}).encode(),
        headers={"Authorization":f"Bearer {TOKEN}","Content-Type":"application/json"})
    return json.load(urllib.request.urlopen(r))
q="""query($zone:String!,$f:ZoneHttpRequestsAdaptiveGroupsFilter_InputObject!){viewer{zones(filter:{zoneTag:$zone}){
  byUA:httpRequestsAdaptiveGroups(limit:15,filter:$f,orderBy:[count_DESC]){count dimensions{userAgent}}
  byPath:httpRequestsAdaptiveGroups(limit:12,filter:$f,orderBy:[count_DESC]){count dimensions{clientRequestPath}}}}}"""
r=gql(q,{"zone":ZONE,"f":{"datetime_geq":T0,"datetime_leq":T1,"clientCountryName":CC}})
if r.get("errors"): print("ERRORS:",json.dumps(r["errors"])[:600]); raise SystemExit
z=r["data"]["viewer"]["zones"][0]
print(f"== {CC} user agents =="); [print(f'  {g["count"]:6}  {g["dimensions"]["userAgent"][:110]}') for g in z["byUA"]]
print(f"== {CC} paths ==");       [print(f'  {g["count"]:6}  {g["dimensions"]["clientRequestPath"][:100]}') for g in z["byPath"]]
EOF
```

Repeat per suspect country. Counts here are **sampled** — treat them as ratios, not absolutes.

### 4 · ✔ What are the suspects being served? + is bot protection actually on?

First, status codes for the dominant suspect UA — same query shape as step 3, dimension
`edgeResponseStatus`, plus a UA filter such as `"userAgent_like": "curl%"` (or whatever step 3
surfaced). The status mix is what separates "probing and getting nothing" from "probing and getting
served".

Then the bot config — **via API only**:

```sh
curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/bot_management" | \
  python3 -c "import json,sys; r=json.load(sys.stdin)['result']; print({k:r.get(k) for k in ('fight_mode','enable_js','ai_bots_protection','crawler_protection')})"
```

> ⚠ **Never infer Bot Fight Mode by curling the site.** The edge cache answers before the bot check
> fires, so `cf-cache-status: HIT` masks the real state — and Workers Static Assets / Pages ignore query
> strings in the cache key, so `?cachebust=1` does not help either. The API read above is the only
> trustworthy check. (This is also why `web.cloudflare.md` §1c verifies the setting through
> `/bot_management` rather than a request against the site.)

This is also where a step-change date from step 2 gets explained: a toggle flipped on that date will
show here as the corresponding `fight_mode` / `crawler_protection` state.

### 5 · ✔ Classify — signature table

| Signature (UA × paths × status) | Classification |
|---|---|
| `curl/*` or a bare HTTP client × credential paths (`.env`, `.aws`, `*.tfvars`, `/api/v1/secrets`, `config.*`), often with `%2e`-encoded dots | **Secret/vuln scanner.** Cheap-VPS regions are the classic origins. |
| `HeadlessChrome/*`, `python-requests/*` × real app routes and API endpoints, sustained volume | **Content scraper.** |
| `Mozilla/5.0 (compatible; SomethingBot/x; +url)` × `robots.txt` + a shallow crawl | **Declared crawler** (Ahrefs, MJ12, PetalBot…) — nuisance, not threat. |
| Ancient browser UAs (MSIE, Presto-Opera) in volume | Botnet noise wearing old costumes. |
| Volume step-change on a specific date | **Config change until proven otherwise** — cross-check step 4 before calling it an attack. |

> **SPA catch-all caveat.** A single-page app that returns `200` for every path serves scanners the app
> shell with `HTTP 200` — the scanner logs a "hit" on *every* probe and keeps coming back. High scanner
> volume + all-`200`s on a static SPA is a **feedback loop, not a breach**. Confirm nothing sensitive
> actually resolves (the shell is not the secret), then decide whether breaking the loop is worth a rule.
> This is a direct consequence of `SITE_TYPE=spa` in `web.cloudflare.md` — the same
> `not_found_handling: single-page-application` line that makes deep links work is what feeds the scanner.

### 6 · ✔ Write back — findings block

Append a run entry to the ledger below: **JSON first, prose second**, so another agent can consume the
finding without re-running the triage. Cross-post a pointer wherever the fleet will look for it (project
memory, a `MANIFEST.toml` note) — a finding that lives only in a chat is not a finding.

## Provider gotchas (Free plan — don't relearn these live)

- **`httpRequestsAdaptiveGroups` is capped at a 1-day range on Free** (error code `quota`). Multi-day
  country totals must come from `httpRequests1dGroups.sum.countryMap` instead — hence the two-query
  shape of steps 2 and 3. Loop the adaptive query per-day if you need a multi-day drill-down.
- **`clientASNDescription` (and siblings) are not exposed on Free** (`authz` error). You get country, UA,
  path, and status; ASN attribution needs a paid plan. Say "consistent with a cheap-VPS host" — do not
  claim a specific network you cannot see.
- **Adaptive counts are sampled.** Ratios are trustworthy; absolutes are not. Never put a sampled number
  in a sentence that implies precision.
- **Bot Fight Mode state is invisible to curl probes** (cache HITs) — API read only, step 4.
- **Scanners URL-encode dots** (`%2e`) to slip past naive path filters — match both forms in any WAF
  rule you write, or the rule quietly covers half the traffic.

## Response Playbook (the only non-read-only section)

- **Do nothing** — often correct, and it is a *decision*. A static site behind free egress with nothing
  sensitive resolving: scanner noise is an analytics blemish, not a cost or an exposure. State the
  conclusion explicitly in the ledger so silence reads as a judgment rather than an oversight.
- 🟡 **WAF custom rule** (the free plan includes custom rules) — block or `404` the probe patterns: path
  contains `.env`, `%2e`, `.aws`, `.tfvars`, `wp-`, and friends. This breaks the SPA `200`-feedback loop
  at its source.
  > **Carve out the agent surface first.** Never match `/llms.txt`, `/.well-known/*`, or a release
  > page's install/pack paths, and **never block on a `curl` user-agent** — an Ephemera release page
  > exists to be fetched by exactly that shape of client (see `releases-page-clerk.md`). A rule written
  > against "scanner-looking traffic" without these carve-outs takes the agent surface down with it.
- 🔴 **Re-enabling Bot Fight Mode is a red flag on any zone serving an agent surface.** Free-tier BFM is
  **zone-wide with no path exclusions**, and it swats precisely the `curl`/`fetch`-shaped traffic the
  agent surface exists to serve. This is why `web.cloudflare.md`'s `BOT_PROTECTION` knob defaults to
  `off` on zones that publish an agent surface. If mitigation is genuinely required, it is **targeted WAF
  rules** or **Super Bot Fight Mode with verified-bots allowed** — never the blunt free toggle.

## Dependency frontier

```
token (0) → zone ID + plan (1) → coarse shape picks suspect countries AND step-change dates (2)
          → drill-down needs those country codes (3) → status/config check needs the dominant UA
            from (3) and the dates from (2) → classification (5) needs all of it
```

Each step's filter values come from the previous step's output — **no step is skippable**, and running
step 3 without step 2 means drilling into whichever country the human guessed rather than the one that
actually leads the ranking.

## Deliberately not included

- **create / teardown** — nothing is provisioned. This is attribution, not infrastructure; that is why
  this is a recipe and not a `<stack>.<provider>.md` plan.
- **Logpush / raw logs** — paid, and the entire point is answering from the analytics API without them.
- **ASN attribution** — the field is paywalled (see Gotchas); classification works without it.
- **A scheduling / alerting wrapper** — a cron consumer of this runbook is its own small plan, and would
  need a baseline store this recipe deliberately does not carry.

## Runs (findings ledger)

A worked example, genericized — the *shape* is the contract, so another agent knows what a completed
triage looks like. Counts are illustrative magnitudes from the originating live run.

### <YYYY-MM-DD> · example.com (`<ZONE_ID>`, Free)

```json
{
  "date": "<YYYY-MM-DD>",
  "zone": "example.com",
  "trigger": "FR traffic ~2x US in dashboard",
  "top_countries_7d": {"SG": 15364, "FR": 10230, "US": 8589, "NL": 5397},
  "findings": [
    {"class": "secret-scanner", "cc": "FR", "ua": "curl/8.7.1", "sampled_24h": 2736,
     "paths": [".env variants", "api/v1/secrets", "terraform.tfvars", ".aws", "%2e-encoded"],
     "status_mix": {"200": 2632, "301": 56, "405": 48},
     "note": "SPA catch-all feeds it 200s (app shell) — feedback loop, no exposure"},
    {"class": "content-scraper", "cc": "SG", "ua": "HeadlessChrome/145 + python-requests/2.27.1",
     "paths": ["/api/items", "/api/rates", "app-data.js"], "note": "real app routes"},
    {"class": "baseline-shift", "date": "<YYYY-MM-DD>", "from": 3600, "to": 8200,
     "cause": "Bot Fight Mode turned off zone-wide (agent-surface decision), confirmed fight_mode:false via API"}
  ],
  "action": "none; WAF probe-path rule drafted as an option, not applied",
  "crossposts": ["memory/<project>-traffic-baseline.md"]
}
```

**Verdict:** no incident. The country the human flagged was one credential scanner talking to itself;
a different country was the real volume leader; and the traffic step-change was the accepted, already-
decided cost of keeping the agent surface reachable.
