Interactive TUI for analyzing Envoy + Coraza (OWASP CRS) WAF logs and generating seclang rule exceptions.
It correlates each Envoy access-log line with the Coraza rule-trigger lines that
share its request id (order-independent), lets you drill down vhost → path →
request → violation, mark false positives, track which requests you've already
analyzed, and generate copy-pasteable SecRule ... ctl:ruleRemoveTargetById=...
exceptions per vhost.
CRS aggregation rules (949*, 959*, 980*) are hidden from the violation
lists but their score is shown.
On a wide terminal the screen splits: the left pane is the navigable view and
the right pane shows two vertical bar charts (inbound and outbound anomaly score
→ number of requests). The charts follow the left context (all traffic on the
vhost list, the selected vhost once entered) and the active IP filter. They
exclude score 0, gap-fill every score from the lowest up to 50 (extending to 100
when higher scores exist) with a final 100+ bucket, label the x-axis every 5,
show a y-axis scale, and cap the y-axis so a single spike doesn't flatten the
other bars (very wide score ranges fall back to 5-point bins). The charts
auto-hide when the terminal is too narrow. The inbound/outbound anomaly scores are read
from rule 980170 (the CRS reporting_level correlation rule, logged for
every request — its detection value), falling back to 949110/959* for
logs captured without reporting_level. The by-score and request lists show
both IN and OUT columns.
go build -o eg-coraza-analyzer .
./eg-coraza-analyzer ../k8s-demo-cluster/eg-coraza.log # or any Envoy/Coraza log
./eg-coraza-analyzer --start-id 2000 mylog.log # first generated SecRule id
# Read straight from a pod (piped stdin is auto-detected):
kubectl logs <envoy-pod> | ./eg-coraza-analyzer
./eg-coraza-analyzer - < mylog.log # "-" forces stdinWith no argument and an interactive terminal it reads eg-coraza.log in the
current directory. When stdin is piped (or - is given) it reads the log from
stdin and takes keyboard input from the controlling terminal (/dev/tty).
Vhosts ─enter→ Section ─enter→ Requests ─enter→ Request detail
│ (by path) (one request,
│ its violations)
└ by score: Section ─enter→ Request detail (directly)
| Screen | Keys |
|---|---|
| All | ↑/↓ j/k move · enter/→ open · esc/← back · / filter · g export · q quit |
| Vhosts | columns: FLAGGED / CLEAN / TOTAL requests (flagged = has a rule violation or anomaly score) |
| Section | s toggle by path ⇄ by anomaly score |
| Request list | one row per request; columns SCORE / METHOD / URI / VIOL / RESP |
| Request detail | per-request fields + one row per violation (rule, PL, category, match, data) |
| Export | ↑/↓ scroll · esc back · q quit |
False-positive marking (f/F/A) — drives the generated exceptions:
f— on the request-detail screen marks the focused violation; on list screens it marks the whole focused request.F— mark the whole focused request.A— mark every violation on the current path.
Analyzed tracking (space / m) — independent of FP marks; just records what
you've reviewed so you can work through a noisy log methodically. Marks a request
(or a whole path, from the path list) analyzed. Analyzed rows show a ✓ and dim;
paths show an n/m analyzed progress counter. Row status prefix: ✓ analyzed,
* fully FP-marked (· = partial). Footer shows live FP and Analyzed counts.
Both FP and analyzed marks are in-memory only — generate the export (g)
before quitting. On exit, the generated exceptions are also printed to stdout so
they land in your terminal scrollback for copy-paste. (Analyzed marks do not
affect the export.)
Client-IP filter (/) — type a single IP (172.18.0.1) or a CIDR
(172.18.0.0/16), enter to apply, esc to cancel. The filter is global:
it rebuilds the dataset so every screen (vhost counts, paths, scores, requests)
reflects only matching requests. An active filter shows a banner under the title
(Filter: client <ip/cidr> (matched/total requests)); submit an empty value to
clear it. X-Forwarded-For lists match if any address matches.
internal/parse— log → correlated[]Requestinternal/model— domain types, vhost/path aggregation, FP mark set (fpset.go), analyzed/review set (review.go), client-IP filter (ipfilter.go), 920420 body-handling remediation (remediation.go)internal/export— false-positive marks → seclang exceptionsinternal/tui— Bubble Tea screens & key handling
The export directive de-duplication mirrors the logic in
../k8s-demo-cluster/waf-analyze2.py.
A violation Coraza could not attribute to a variable (no match name, or
UNKNOWN) has no target to remove, so the rule itself is dropped for the path
with ctl:ruleRemoveById=<id>; that also supersedes any target-specific
directive for the same rule.
Some rules need a different exception than disabling a target:
-
911100 (method enforcement) — instead of
ctl:ruleRemoveTargetById, the offending HTTP method(s) are appended to the CRS defaults in a singlesetvar:'tx.allowed_methods=GET HEAD POST OPTIONS <extra…>'directive (offending methods read from the violation'smatch.value). -
920420 (content-type not allowed) — disabling the target is wrong; the rule is disabled and the body is handled explicitly. Marking a 920420 false positive opens a body-handling picker (
jJSON ·xXML ·uURLENCODED ·mMULTIPART ·obody-access-off), stored per path; pressbto change it. The export emits a chained rule per offending content-type:SecRule REQUEST_HEADERS:Content-Type "@beginsWith text/plain" "id:N,phase:1,pass,t:none,nolog,chain" SecRule REQUEST_URI "@beginsWith /path" "t:none,ctl:ruleRemoveById=920420,ctl:requestBodyProcessor=JSON"(the last
ctlisctl:requestBodyProcessor=<PROC>orctl:requestBodyAccess=off). -
920450 (PL1) & 920451 (PL2) (HTTP header restricted by policy) — removing the target would disable the check for all restricted headers, so the rule is removed only for requests that actually carry the offending header (taken from the violation's
match.key). The export emits a chained rule per offending header; if both PL1 and PL2 fired for the same header, both ids are removed in the same chain:SecRule &REQUEST_HEADERS:x-http-method-override "@ge 1" "id:N,phase:1,pass,t:none,nolog,chain" SecRule REQUEST_URI "@beginsWith /path" "t:none,ctl:ruleRemoveById=920450"No extra input is needed — marking the violation false-positive is enough.