The controller said zero firewall rules. The dashboard showed a policy table with 109 entries. Only one of those was true, and the API response was the wrong one.
That is the argument for auditing by API rather than by screenshot: the API is the source of truth, but only if you ask the right endpoint and check the answer against reality.
Read-only first, and only
An audit account should have the smallest permission that produces the data:
| Permission | Why |
|---|---|
| Read network configuration | Networks, WLANs, policies, devices |
| Read site statistics | Clients, health, events |
| No write scope | A script with write access will eventually write |
If the platform cannot express read-only at the API level, do not automate with that account. The first time a loop runs twice, you will learn why.
Ask the endpoint that answers
Every controller has legacy endpoints that return plausible, empty results. UniFi Network 9 is the example in front of us:
| Question | Endpoint | Reality |
|---|---|---|
| Firewall rules | /rest/firewallrule |
Returns [] — legacy |
| Firewall policies | /proxy/network/v2/api/site/default/firewall-policies |
109 policies |
| Networks | /rest/networkconf |
Correct |
| Devices | /stat/device |
Correct |
The lesson generalizes: a 200 response with an empty list is not proof of an empty configuration. Check one known object before trusting any endpoint.
The snapshot pattern
1. Authenticate (cookie + CSRF token).
2. GET each endpoint, save the raw JSON under a timestamped directory.
3. Compute a summary: counts, isolated networks, policy hits, device firmware.
4. Diff against the previous snapshot.
5. Store 90 days; alert on unexpected differences.
A snapshot is not a backup. It is evidence: what the network looked like at a moment, in a form you can compare and search.
Worked example: the minimum useful script
import json, requests
BASE = "https://10.0.0.46"
s = requests.Session()
s.post(f"{BASE}/api/auth/login", json={"username": USER, "password": PASS}, verify=False)
s.headers["X-CSRF-Token"] = s.cookies.get("csrf_token", "")
endpoints = {
"networks": "/proxy/network/api/s/default/rest/networkconf",
"devices": "/proxy/network/api/s/default/stat/device",
"wlans": "/proxy/network/api/s/default/rest/wlanconf",
"policies": "/proxy/network/v2/api/site/default/firewall-policies",
}
out = {}
for name, path in endpoints.items():
r = s.get(f"{BASE}{path}", verify=False)
out[name] = r.json()
json.dump(out, open("snapshot.json", "w"), indent=1)
Then assert on the result: len(out["policies"]) > 0, or the script has failed silently and should say
so rather than producing an empty report.
What to diff
| Change | Why it matters |
|---|---|
| New network or VLAN | Undocumented address plan growth |
| Policy added/removed/reordered | The most consequential change in the system |
| WLAN security change | PSK rotation or a downgrade |
| Firmware version change | Explains behavior changes after an auto-update |
| Device offline for >24h | Inventory drift |
A weekly diff of those five items is a security control and a documentation check at the same time.
Never point an audit script at production with write scope
The account is the safety mechanism. Keep it read-only at the controller, not by convention in the script, and never store its credentials in the repository.
What breaks in production
- Trusting a 200 with an empty body. The endpoint changed, or you are calling the legacy path.
- Pagination. Some endpoints return the first page and a cursor; the rest is silently missing.
- API version drift after a controller upgrade. Fields move; the script keeps running and produces wrong summaries.
- Credentials in code. Once committed, rotate.
- No assertion on the output. An empty snapshot looks like a healthy, quiet network.
Checklist
- The audit account is read-only at the controller.
- Every endpoint used has been validated against a known object.
- Snapshots are timestamped, retained, and diffed.
- The script asserts on expected counts before reporting.
- Credentials live outside the repository and are rotated on a schedule.
Next
Auditing tells you what changed. Continue with Change Windows and Blast Radius to decide how to change it safely.