feat(crowdsec): Add geo heatmap visualization for threat origins

- New heatmap.js component with SVG world map and country centroids
- Colored dots show threat distribution: orange (local), cyan (CAPI), red (WAF)
- Dot size scales logarithmically with threat count (4-20px)
- Hover tooltips show country code and count
- Added geo_local_raw and geo_capi_raw fields to RPCD backend
- CAPI geo extraction from decisions with GeoIP metadata
- CSS styling for heatmap container, dots, and legend

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-03-16 09:28:48 +01:00
parent b02503eac4
commit f46e145927
5 changed files with 299 additions and 8 deletions

View File

@ -1,6 +1,25 @@
# SecuBox UI & Theme History
_Last updated: 2026-03-16 (Quick Access QR Page)_
_Last updated: 2026-03-16 (CrowdSec Heatmap)_
0. **CrowdSec Dashboard Geo Heatmap (2026-03-16)**
- NEW: Geo heatmap visualization showing threat origins on world map
- SVG-based world map with continent outlines
- Colored dots at country centroids (orange=local bans, cyan=CAPI, red=WAF)
- Dot size scales logarithmically with threat count
- Hover tooltips show country + count
- Legend showing all three data sources
- Files: `heatmap.js` (new), `overview.js`, `dashboard.css`, `luci.crowdsec-dashboard`
- Backend: Added `geo_local_raw` and `geo_capi_raw` fields to overview cache
- CAPI geo extraction from decisions with GeoIP metadata
0. **CrowdSec Dashboard Fixes (2026-03-16)**
- Fixed alerts/scenarios/countries stats showing 0 despite active bans
- Fixed RPCD blocking causing LuCI crashes (async refresh_cache via background subshell)
- Fixed firewall bouncer HTTP 404 (wrong API URL in UCI config: 8090 → 8190)
- Fixed WAF bans showing 0 (grep for `mitmproxy-` prefix not specific scenario)
- Fixed JSON parsing errors (removed string wrapper from array fields)
- Added cron job for background cache refresh (every minute)
0. **SecuBox Quick Access QR Page (2026-03-16)**
- QR code landing page deployed at https://quick.secubox.in/

View File

@ -119,7 +119,51 @@
.cs-geo-count { font-weight: 600; }
.cs-country { font-size: 0.625rem; color: var(--cs-muted); }
/* Heatmap */
.cs-heatmap-container {
position: relative;
width: 100%;
background: var(--kiss-bg2, #1a1d21);
border-radius: 8px;
overflow: hidden;
}
.cs-heatmap-svg {
display: block;
width: 100%;
height: auto;
}
.cs-heatmap-dot {
transition: transform 0.2s ease, opacity 0.2s ease;
cursor: pointer;
}
.cs-heatmap-dot:hover {
transform: scale(1.4);
opacity: 1 !important;
}
.cs-heatmap-legend {
display: flex;
justify-content: center;
gap: 24px;
padding: 12px 0;
background: rgba(0,0,0,0.2);
border-top: 1px solid var(--kiss-border, #2d3139);
}
.cs-heatmap-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--kiss-muted, #8b949e);
}
.cs-heatmap-legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
@media (max-width: 768px) {
.cs-stats { grid-template-columns: repeat(2, 1fr); }
.cs-grid-2 { grid-template-columns: 1fr; }
.cs-heatmap-legend { flex-direction: column; align-items: center; gap: 8px; }
}

View File

@ -0,0 +1,182 @@
'use strict';
'require baseclass';
/* global baseclass */
/**
* CrowdSec Heatmap Component
* Renders threat origins as colored dots on a world map
*/
// Country centroids (approximate lat/long for major countries)
var CENTROIDS = {
'US': [39.8, -98.5], 'CN': [35.0, 105.0], 'RU': [61.5, 105.0],
'DE': [51.0, 9.0], 'FR': [46.0, 2.0], 'GB': [54.0, -2.0],
'JP': [36.0, 138.0], 'BR': [-14.0, -51.0], 'IN': [20.0, 77.0],
'AU': [-25.0, 133.0], 'CA': [56.0, -106.0], 'KR': [36.0, 128.0],
'NL': [52.0, 5.5], 'IT': [42.0, 12.0], 'ES': [40.0, -4.0],
'PL': [52.0, 19.0], 'UA': [49.0, 32.0], 'ID': [-5.0, 120.0],
'TR': [39.0, 35.0], 'SA': [24.0, 45.0], 'MX': [23.0, -102.0],
'TH': [15.0, 100.0], 'VN': [16.0, 108.0], 'PH': [13.0, 122.0],
'SG': [1.3, 103.8], 'MY': [4.0, 101.0], 'HK': [22.3, 114.2],
'TW': [23.5, 121.0], 'AR': [-34.0, -64.0], 'CL': [-33.0, -70.0],
'CO': [4.0, -72.0], 'PE': [-10.0, -76.0], 'VE': [7.0, -66.0],
'ZA': [-29.0, 24.0], 'EG': [27.0, 30.0], 'NG': [10.0, 8.0],
'KE': [-1.0, 38.0], 'MA': [32.0, -6.0], 'IR': [32.0, 53.0],
'PK': [30.0, 69.0], 'BD': [24.0, 90.0], 'SE': [62.0, 15.0],
'NO': [62.0, 10.0], 'FI': [64.0, 26.0], 'DK': [56.0, 10.0],
'CH': [47.0, 8.0], 'AT': [47.5, 14.5], 'BE': [50.5, 4.5],
'CZ': [49.8, 15.5], 'PT': [39.5, -8.0], 'GR': [39.0, 22.0],
'RO': [46.0, 25.0], 'HU': [47.0, 20.0], 'IL': [31.0, 35.0],
'AE': [24.0, 54.0], 'NZ': [-41.0, 174.0], 'IE': [53.0, -8.0]
};
// Convert lat/long to SVG coordinates (Mercator-ish projection)
function toSvgCoords(lat, lon, width, height) {
var x = (lon + 180) * (width / 360);
var latRad = lat * Math.PI / 180;
var mercN = Math.log(Math.tan((Math.PI / 4) + (latRad / 2)));
var y = (height / 2) - (width * mercN / (2 * Math.PI));
return [x, Math.max(0, Math.min(height, y))];
}
// Calculate dot radius based on count (logarithmic scale)
function dotRadius(count, maxCount) {
if (count <= 0) return 4;
var normalized = Math.log(count + 1) / Math.log(maxCount + 1);
return 4 + normalized * 16; // 4px to 20px
}
return baseclass.extend({
/**
* Render heatmap
* @param {Object} data - { local: [{country, count}], capi: [...], waf: [...] }
* @param {Object} options - { width, height, showLegend }
*/
render: function(data, options) {
var width = options && options.width || 600;
var height = options && options.height || 300;
var showLegend = options && options.showLegend !== false;
// Find max count for scaling
var maxCount = 1;
['local', 'capi', 'waf'].forEach(function(key) {
if (data[key]) {
data[key].forEach(function(item) {
if (item.count > maxCount) maxCount = item.count;
});
}
});
// Create SVG element
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
svg.setAttribute('class', 'cs-heatmap-svg');
svg.style.width = '100%';
svg.style.height = 'auto';
// Add world outline background
var bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bg.setAttribute('width', width);
bg.setAttribute('height', height);
bg.setAttribute('fill', 'var(--kiss-bg2, #1a1d21)');
svg.appendChild(bg);
// Simple continent outlines (simplified paths)
var continents = document.createElementNS('http://www.w3.org/2000/svg', 'path');
continents.setAttribute('d', this.getContinentPaths(width, height));
continents.setAttribute('fill', 'none');
continents.setAttribute('stroke', 'var(--kiss-border, #2d3139)');
continents.setAttribute('stroke-width', '0.5');
svg.appendChild(continents);
// Render dots by layer (CAPI first, then local, then WAF on top)
var layers = [
{ key: 'capi', color: 'var(--kiss-cyan, #00bcd4)', label: 'CAPI Blocklist' },
{ key: 'local', color: 'var(--kiss-orange, #ff9800)', label: 'Local Bans' },
{ key: 'waf', color: 'var(--kiss-red, #f44336)', label: 'WAF Threats' }
];
var self = this;
layers.forEach(function(layer) {
if (!data[layer.key]) return;
data[layer.key].forEach(function(item) {
var centroid = CENTROIDS[item.country];
if (!centroid) return;
var coords = toSvgCoords(centroid[0], centroid[1], width, height);
var radius = dotRadius(item.count, maxCount);
var circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', coords[0]);
circle.setAttribute('cy', coords[1]);
circle.setAttribute('r', radius);
circle.setAttribute('fill', layer.color);
circle.setAttribute('opacity', '0.7');
circle.setAttribute('class', 'cs-heatmap-dot');
circle.setAttribute('data-country', item.country);
circle.setAttribute('data-count', item.count);
circle.setAttribute('data-source', layer.key);
// Tooltip on hover
var title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
title.textContent = item.country + ': ' + item.count + ' (' + layer.label + ')';
circle.appendChild(title);
svg.appendChild(circle);
});
});
// Container with legend
var container = E('div', { 'class': 'cs-heatmap-container' }, [svg]);
if (showLegend) {
var legend = E('div', { 'class': 'cs-heatmap-legend' }, layers.map(function(l) {
return E('span', { 'class': 'cs-heatmap-legend-item' }, [
E('span', {
'class': 'cs-heatmap-legend-dot',
'style': 'background:' + l.color
}),
' ' + l.label
]);
}));
container.appendChild(legend);
}
return container;
},
// Simplified continent outline paths
getContinentPaths: function(w, h) {
// Simplified world outline - major landmasses
var scale = w / 600;
return [
// North America
'M50,80 Q80,60 120,70 L150,90 Q140,120 100,140 L60,130 Q40,110 50,80',
// South America
'M100,160 Q120,150 130,170 L140,220 Q130,260 110,280 L90,260 Q80,200 100,160',
// Europe
'M280,60 Q320,50 350,60 L360,90 Q340,100 300,95 L280,80 Z',
// Africa
'M280,120 Q320,100 350,120 L360,180 Q340,220 300,230 L270,200 Q260,150 280,120',
// Asia
'M360,50 Q450,30 520,50 L550,100 Q530,140 480,150 L400,130 Q370,100 360,50',
// Australia
'M480,200 Q520,190 540,210 L550,240 Q530,260 500,260 L480,240 Q470,220 480,200'
].join(' ');
},
/**
* Parse geo data from various formats
*/
parseGeoData: function(raw) {
if (!raw) return [];
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch(e) { return []; }
}
if (!Array.isArray(raw)) return [];
return raw.filter(function(item) {
return item && item.country && typeof item.count === 'number';
});
}
});

View File

@ -3,6 +3,7 @@
'require dom';
'require poll';
'require crowdsec-dashboard.api as api';
'require crowdsec-dashboard.heatmap as heatmap';
'require secubox/kiss-theme';
return view.extend({
@ -81,12 +82,26 @@ return view.extend({
return Array.isArray(alerts) ? alerts : [];
},
parseGeoData: function(raw) {
if (!raw) return [];
if (typeof raw === 'string') {
try { raw = JSON.parse(raw); } catch(e) { return []; }
}
if (!Array.isArray(raw)) return [];
return raw.filter(function(item) {
return item && item.country && typeof item.count === 'number';
});
},
render: function(data) {
var self = this;
// Ensure s is always an object (data could be error code like 5)
var s = (data && typeof data === 'object' && !Array.isArray(data)) ? data : {};
s.countries = this.parseCountries(s);
s.alerts = this.parseAlerts(s);
// Parse geo data for heatmap
s.geoLocal = this.parseGeoData(s.geo_local_raw || s.top_countries_raw);
s.geoCapi = this.parseGeoData(s.geo_capi_raw);
var content = [
// Header
@ -113,8 +128,8 @@ return view.extend({
KissTheme.card('System Health', this.renderHealth(s))
]),
// Geo card
KissTheme.card('Threat Origins', E('div', { 'id': 'cs-geo' }, this.renderGeo(s.countries)))
// Geo card - heatmap + legend
KissTheme.card('Threat Origins', E('div', { 'id': 'cs-geo' }, this.renderGeo(s)))
];
poll.add(L.bind(this.pollData, this), 30);
@ -203,19 +218,33 @@ return view.extend({
}));
},
renderGeo: function(countries) {
var entries = Object.entries(countries || {});
if (!entries.length) {
renderGeo: function(s) {
var countries = s.countries || {};
var entries = Object.entries(countries);
var hasData = entries.length > 0 || (s.geoLocal && s.geoLocal.length > 0) || (s.geoCapi && s.geoCapi.length > 0);
if (!hasData) {
return E('div', { 'style': 'text-align: center; padding: 24px; color: var(--kiss-muted);' }, 'No geographic data');
}
// Render heatmap
var heatmapEl = heatmap.render({
local: s.geoLocal || [],
capi: s.geoCapi || [],
waf: [] // WAF geo data could be added later
}, { width: 600, height: 300 });
// Flag legend below heatmap
entries.sort(function(a, b) { return b[1] - a[1]; });
return E('div', { 'style': 'display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 12px;' }, entries.slice(0, 12).map(function(e) {
var flagLegend = E('div', { 'style': 'display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 12px; margin-top: 16px;' }, entries.slice(0, 12).map(function(e) {
return E('div', { 'style': 'display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(255,255,255,0.02); border-radius: 6px;' }, [
E('span', { 'style': 'font-size: 18px;' }, api.getCountryFlag(e[0])),
E('span', { 'style': 'font-family: monospace; font-weight: 600; color: var(--kiss-orange);' }, String(e[1])),
E('span', { 'style': 'font-size: 11px; color: var(--kiss-muted);' }, e[0])
]);
}));
return E('div', {}, [heatmapEl, flagLegend]);
},
fmt: function(n) {
@ -231,12 +260,14 @@ return view.extend({
var s = (data && typeof data === 'object' && !Array.isArray(data)) ? data : {};
s.countries = self.parseCountries(s);
s.alerts = self.parseAlerts(s);
s.geoLocal = self.parseGeoData(s.geo_local_raw || s.top_countries_raw);
s.geoCapi = self.parseGeoData(s.geo_capi_raw);
var el = document.getElementById('cs-stats');
if (el) dom.content(el, self.renderStats(s));
el = document.getElementById('cs-alerts');
if (el) dom.content(el, self.renderAlerts(s.alerts));
el = document.getElementById('cs-geo');
if (el) dom.content(el, self.renderGeo(s.countries));
if (el) dom.content(el, self.renderGeo(s));
});
},

View File

@ -2457,6 +2457,19 @@ refresh_overview_cache() {
awk '{print "{\"country\":\"" $2 "\",\"count\":" $1 "}"}' | \
tr '\n' ',' | sed 's/,$//')
# Parse CAPI geo from decisions with origin=CAPI
# We fetch decisions again with higher limit for CAPI geo
local capi_geo=""
local capi_decisions_full
capi_decisions_full=$("$CSCLI" decisions list -o json 2>/dev/null || echo "[]")
capi_geo=$(echo "$capi_decisions_full" | \
grep -A20 '"origin":"CAPI"' | \
grep -A1 '"key": "IsoCode"' | grep '"value":' | \
sed 's/.*"value"[[:space:]]*:[[:space:]]*"//;s/".*$//' | \
grep -v '^$' | sort | uniq -c | sort -rn | head -20 | \
awk '{print "{\"country\":\"" $2 "\",\"count\":" $1 "}"}' | \
tr '\n' ',' | sed 's/,$//')
# Scenario count from files
local sc1=$(ls -1 /etc/crowdsec/scenarios/*.yaml 2>/dev/null | wc -l)
local sc2=$(ls -1 /srv/crowdsec/hub/scenarios/*/*.yaml 2>/dev/null | wc -l)
@ -2557,6 +2570,8 @@ refresh_overview_cache() {
"scenario_count":$scenario_count,
"top_scenarios_raw":[$scenarios],
"top_countries_raw":[$countries],
"geo_local_raw":[$countries],
"geo_capi_raw":[$capi_geo],
"decisions_raw":$(cat "$decisions_file"),
"alerts_raw":$(cat "$alerts_file"),
"logs":$logs_json,