Episode 14: an agent reads the WAF blocks, a script beats it, a human merges

ops-readonly gets two read-only questions, waf_blocked and waf_rule_info. The
rule ids per blocked request are in CrowdSec's alerts, not in its log, and
alerts need machine credentials that can also write, so waf-export.py runs on
the host and the container gets one more read-only mount.

Workflow G lets a local 3B model draft CRS route exclusions as a Forgejo issue.
The model never writes rule syntax, a code node checks every field against the
tool output, the only write permission is an issue in one repo, trusted sources
live in the workflow and the trigger needs a token.

waf-propose.py does the same without a model: 5 of 5 false positives, no
exclusion for an attack, 0.03 s. The model proposed an exclusion for nearly
every attack it saw. Numbers and the traps we hit are in HARDENING.md.
This commit is contained in:
Maximilian Grimm 2026-09-19 12:56:39 +02:00
parent 696f3e311d
commit 3edef38ba3
13 changed files with 987 additions and 1 deletions

View file

@ -257,3 +257,75 @@ it, not editing a workflow.
The measured comparison (correct numbers, invented numbers, runtime, tokens,
and a prompt-injection test through a request path in the log) is in
`lab-recordings-ep11/FACTS.md`.
## G - WAF exception advisor (episode 14)
The OWASP CRS from episode 13 blocks legitimate work until somebody writes
route exclusions. This workflow reads what the WAF blocked and drafts those
exclusions. It cannot apply them.
```
Start (webhook) -> Load skill -> Build prompts -> WAF exception advisor (agent)
|- waf_blocked (ops-readonly)
|- waf_rule_info (ops-readonly)
-> Check proposal (code) -> Build issue (code) -> Deliver? -> Open issue for a human
```
Three decisions carry the design:
1. **The model never writes rule syntax.** It returns host, path, match type
and rule ids as JSON. `Build issue` renders the SecLang from those fields
after `Check proposal` has validated each one against what `waf_blocked`
really returned in this run: host seen, plain path, not `/`, ids that fired
on that route, never 949110/959100.
2. **A format check cannot see intent.** A proposal to exclude `/.env` on the
vault is perfectly well formed. The only field in a blocked request that the
sender cannot forge is the source address, so the workflow carries a list
of trusted sources and the check drops every route on which no trusted
source was ever blocked. Path, payload and auth header can all be copied.
3. **The only write permission is an issue.** The `Forgejo WAF advisor (issues
only)` credential belongs to a bot user that is a read-only collaborator on
one private repo, with the token scopes `write:issue` and
`read:repository`. Measured: open an issue 201, write a file 403, read
another private repo 404. A pull request would need push rights somewhere;
an issue needs none. A human copies the snippet into
`appsec-rules/crs-route-exclusions.yaml` and rolls it out.
### Wiring
1. On the host: `ops-readonly/waf-export.{service,timer}` (see
`ops-readonly/waf-export.py` for why this runs outside the container), then
`docker compose --profile agent up -d ops-readonly`.
2. In Forgejo: a private repo for the exclusion file, a bot user with read
access to it, a token with `write:issue,read:repository`. In n8n: a Header
Auth credential `Authorization: token <value>`.
3. Import `g-waf-exception-advisor.json`, fill in the credential placeholders,
adjust the repo in the last node.
4. Cap the output. Ollama has no limit by default and a 3B model can loop in
a long JSON list; our first run generated for 11 min 47 s. `numPredict` on
the Ollama node (3000 here; 1500 cut complete answers short), the same cap
in a derived model tag as a second line
(`ollama create qwen2.5-advisor:3b`, `PARAMETER num_predict 3000`), and
`executionTimeout` in the workflow settings.
**After every edit through the REST API, deactivate and activate the
workflow.** A `PATCH` changes the draft; the published nodes keep running.
We measured three runs with settings we believed were live and were not,
the trigger token among them.
5. Put your trusted source IPs into `TRUSTED_SOURCES` at the top of the
`Build prompts` node. They are part of the workflow on purpose. The webhook
is reachable from outside; a caller who may name the trusted sources names
himself, and the check would wave his own routes through.
6. Create the `WAF advisor trigger` credential (Header Auth, name
`x-advisor-token`, a long random value). The webhook refuses calls without
it: a run costs model time and, with `deliver`, opens an issue.
7. Trigger: `POST /webhook/waf-exception-advisor` with the token header and
`{"deliver": true}`. `{"deliver": false}` runs everything except the issue.
### The same proposal without a model
`ops-readonly/waf-propose.py` reads the same `waf_blocked` answer and groups by
host, route and rule id, trusted sources only, at least two blocks. About
twenty lines, 0.03 s, the same output every time. On the frozen measurement
window it found 5 of 5 false positives and proposed nothing for an attack. Run
it first. Numbers for both, and for the injection and mimicry tests, are in
`lab-recordings-ep14/FACTS.md`.