This is an early beta / proof-of-concept. It is a read-only visual toy for a SOC wall, not a hardened product. Expect rough edges, breaking changes, and approximate geolocation. Do not rely on it as a detection or alerting control. Run it on a trusted internal network only. No warranty.
Norse-style live attack map for Wazuh. A standalone,
dependency-free Python service that polls your local wazuh-indexer for alerts
carrying a routable source IP, geolocates each one, and streams animated arcs
(attacker origin → your sensor "home") to a full-screen canvas page.
Above: the map in demo mode. Arcs are coloured by attack type and converge on the configured sensor home (example: Amsterdam, NL).
Unlike a generic threat-feed map (which plots known-bad infrastructure from the public internet), this map sources its arcs from your own Wazuh alerts — so every arc is a real event recorded against your monitored fleet, not background noise.
- Real attribution, not feeds. Each arc = one Wazuh alert with a public
data.srcip. It shows who is actually hitting your sensors. - Zero dependencies. Pure Python 3 stdlib. No pip, no Node, no database. One file runs the server; the page is a single self-contained HTML/canvas.
- Read-only. Connects to
wazuh-indexerwith a read-only account. It never writes to Wazuh and needs no manager API access. - Upgrade-proof. It is not an OpenSearch Dashboards plugin, so a Wazuh upgrade can't break it. It just talks to the indexer's search API.
wazuh-indexer (wazuh-alerts-*)
│ read-only "monitor" account, basic-auth over TLS
▼
mapserver.py ──► attackmap.py poller ──► geo.py (mmdb / synthetic)
│ │
│ ▼
│ arc events (deduped by timestamp watermark)
▼ │
HTTP :8100 ◄───────────┘
/attackmap (canvas page) /api/attackmap/stream (SSE)
Every MAP_POLL_INTERVAL seconds the poller runs a search for alerts newer than
the last one it saw (range > watermark) that have a data.srcip. Each hit is
turned into an event: the source IP is geolocated, the rule.groups are mapped
to an attack-type bucket (bruteforce / webattack / malware / ransomware / ddos /
recon / intrusion / other), and the event is pushed to all connected browsers
over Server-Sent Events. The browser draws the arc.
| File | Purpose |
|---|---|
mapserver.py |
Standalone HTTP server + LocalIndexerClient (basic-auth → https://127.0.0.1:9200). Production entry point. |
attackmap.py |
Emitter, alert poller, geo bucketing, SSE, the /attackmap canvas page, and the MAP_DEMO synthetic feeder. |
geo.py |
Vendored MaxMind mmdb reader + synthetic fallback. |
world.geojson |
Vendored low-res world outline for the canvas. |
demo.py |
Run the map with synthetic data, no Wazuh needed (python3 demo.py). |
tools/render_shot.py |
Render the static hero PNG (no browser) — used for docs/screenshot.png. |
wazuh-attackmap.service |
systemd unit. |
wazuh-attackmap.env.example |
Env template. |
docs/INSTALL.md |
Step-by-step install manual. |
docs/ADMIN.md |
Operator / admin manual (config, ops, troubleshooting). |
docs/USER.md |
Viewer manual (reading the map, controls). |
GeoLite2-City.mmdb is not shipped (gitignored, ~60 MB). geo.py auto-detects
a copy beside the script, then $GEOIP_MMDB, else falls back to synthetic
scatter. See docs/INSTALL.md.
python3 demo.py # then open http://127.0.0.1:8788/attackmapRegenerate the screenshot (or the 1280×640 social-preview card):
python3 tools/render_shot.py docs/screenshot.png
SHOT_W=1280 SHOT_H=640 python3 tools/render_shot.py docs/social-preview.pngSee docs/INSTALL.md for the full walk-through. Short version:
sudo mkdir -p /opt/wazuh-attackmap && sudo chown "$USER":"$USER" /opt/wazuh-attackmap
cp mapserver.py attackmap.py geo.py world.geojson /opt/wazuh-attackmap/
# drop a GeoLite2-City.mmdb into /opt/wazuh-attackmap/ (optional but recommended)
sudo cp wazuh-attackmap.env.example /etc/wazuh-attackmap.env
sudo chmod 600 /etc/wazuh-attackmap.env # set INDEXER_PASS to a read-only account
sudo cp wazuh-attackmap.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now wazuh-attackmap
curl -s http://127.0.0.1:8100/healthz| Route | Description |
|---|---|
/ |
302 → /attackmap |
/attackmap |
full-screen live map page |
/api/attackmap/world |
vendored world geojson |
/api/attackmap/recent?limit=N |
recent events + home coords (JSON) |
/api/attackmap/stats |
running counters (JSON) |
/api/attackmap/stream |
Server-Sent Events feed |
/healthz |
200 liveness probe |
All config is environment variables — see docs/ADMIN.md for the full table. Most-used:
| Var | Default | Notes |
|---|---|---|
INDEXER_URL |
https://127.0.0.1:9200 |
local wazuh-indexer |
INDEXER_USER / INDEXER_PASS |
monitor / — |
read-only account |
INDEXER_CA |
— | root-ca.pem for TLS verify; unset = verify off |
MAP_PORT |
8100 |
listen port |
MAP_TLS_CERT / MAP_TLS_KEY |
— | set both → serve HTTPS (reuse the Wazuh cert; see docs/INSTALL.md) |
MAP_HOME_LAT / MAP_HOME_LON |
52.37 / 4.90 |
sensor "home" the arcs land on |
MAP_INCLUDE_PRIVATE |
0 |
1 = also plot internal/LAN source IPs (see below) |
MAP_DEMO |
— | 1 = synthetic feeder instead of Wazuh |
By default the map plots only public source IPs — so an attacker already
inside your LAN (lateral movement, internal brute-force, internal recon) using
192.168.x / 10.x / 172.16–31.x addresses produces no arc. RFC1918 addresses
can't be geolocated, so they're dropped.
That is a visualization gap, not a detection gap — Wazuh still records those alerts; the map just doesn't draw them by default.
Set MAP_INCLUDE_PRIVATE=1 to surface them. Internal sources are drawn in a
distinct magenta hue, clustered in a ring around your sensor home (each
internal host gets a stable spot, hashed from its IP). So an internal host
hammering your sensors shows up as magenta arcs near the centre — visually
separate from the multicolour public arcs sweeping in from abroad. Events also
carry "scope":"internal" in the API for downstream filtering.
MAP_INCLUDE_PRIVATE=1 python3 mapserver.py # or set it in /etc/wazuh-attackmap.envHeads-up: on a busy internal network this can be noisy (every internal auth failure becomes an arc). Tune it with
MAP_INTERNAL_MIN_LEVEL— a rule-level floor that drops low-severity internal arcs (public arcs are never filtered). E.g.MAP_INTERNAL_MIN_LEVEL=8keeps brute-force/notable internal events and drops routine single auth failures. See docs/ADMIN.md.
- Sources only routable public
data.srcipby default (see Internal / LAN attackers above forMAP_INCLUDE_PRIVATE). A home/LAN-only Wazuh shows an empty map unless that flag is set — that is correct, not a bug. To see public arcs you need internet-facing sensors (or a honeypot) producing alerts with public source IPs. - Geolocation is approximate (city-level mmdb or, worse, synthetic scatter).
- No auth on the map page itself. Defaults are lab-tuned (no auth,
MAP_BIND=0.0.0.0, HTTP) for a SOC wall on a trusted internal network. For production, put it behind an authenticating reverse proxy, bind to localhost, turn on HTTPS — see SECURITY.md. Never expose it to the internet without proxy + auth. - In-memory only: the event ring buffer resets on restart. No history, no DB.
- Test alerts injected via the analysisd queue carry an old timestamp and fall
behind the poll watermark, so they never emit. Use real traffic or
MAP_DEMO.
Read-only, no dependencies, hardening headers, optional HTTPS, read-only indexer
account, bind control. See SECURITY.md for the threat model and
the accepted POC trade-offs (no page auth, 'unsafe-inline' CSP, etc.).
Proof-of-concept, provided as-is. No warranty, no support guarantee. Licensed under the MIT License.
