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:
parent
696f3e311d
commit
3edef38ba3
13 changed files with 987 additions and 1 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue