feat(crowdsec): Fix threat stats and add bouncer effectiveness dashboard

- Fix top_scenarios parsing from cscli metrics (CAPI blocklist breakdown)
- Add bouncer stats: dropped packets/bytes, processed packets/bytes, active bans
- Update overview.js with threat types visualization (bar charts + percentages)
- Show real stats: Active Bans, Blocked Packets, Blocked Traffic
- Add CSS for threat type icons, progress bars, and severity colors
- Parse CAPI decisions table: ssh:bruteforce, ssh:exploit, generic:scan, tcp:scan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-27 16:14:20 +01:00
parent 9de86dc854
commit 02bb26ad4d
3 changed files with 176 additions and 22 deletions

View File

@ -272,6 +272,66 @@ body.cs-soc-fullwidth #maincontent { margin: 0 !important; width: 100% !importan
.soc-empty-icon { font-size: 32px; margin-bottom: 12px; opacity: 0.5; }
/* Threat Types */
.soc-threat-types { }
.soc-threat-icon {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 4px;
margin-right: 8px;
font-size: 14px;
}
.soc-threat-icon.critical { background: rgba(248, 81, 73, 0.2); }
.soc-threat-icon.high { background: rgba(182, 35, 36, 0.2); }
.soc-threat-icon.medium { background: rgba(210, 153, 34, 0.2); }
.soc-threat-icon.low { background: rgba(88, 166, 255, 0.2); }
.soc-threat-count {
font-weight: 600;
font-family: 'JetBrains Mono', Consolas, monospace;
color: var(--soc-danger);
}
.soc-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
height: 20px;
}
.soc-bar {
height: 8px;
border-radius: 4px;
min-width: 4px;
transition: width 0.3s ease;
}
.soc-bar.critical { background: var(--soc-danger); }
.soc-bar.high { background: #b62324; }
.soc-bar.medium { background: var(--soc-warning); }
.soc-bar.low { background: var(--soc-info); }
.soc-bar-pct {
font-size: 11px;
color: var(--soc-text-muted);
font-family: monospace;
min-width: 35px;
}
.soc-threat-total {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--soc-border);
text-align: right;
font-size: 12px;
color: var(--soc-text-muted);
}
/* Nav */
.soc-nav {
display: flex;

View File

@ -50,8 +50,8 @@ return view.extend({
E('div', { 'class': 'soc-card-body', 'id': 'health-check' }, this.renderHealth(status))
]),
E('div', { 'class': 'soc-card' }, [
E('div', { 'class': 'soc-card-header' }, 'Active Scenarios'),
E('div', { 'class': 'soc-card-body' }, this.renderScenarios(status.scenarios || []))
E('div', { 'class': 'soc-card-header' }, 'Threat Types Blocked'),
E('div', { 'class': 'soc-card-body' }, this.renderThreatTypes(status.top_scenarios_raw))
])
]);
@ -91,16 +91,16 @@ return view.extend({
},
renderStats: function(d) {
var totalBans = (d.total_decisions || 0) + (d.capi_decisions || 0) + (d.local_decisions || 0);
// Use total_decisions if set, otherwise sum capi + local
if (d.total_decisions > 0) totalBans = d.total_decisions;
var totalBans = d.active_bans || d.total_decisions || 0;
var droppedPkts = parseInt(d.dropped_packets || 0);
var droppedBytes = parseInt(d.dropped_bytes || 0);
var stats = [
{ label: 'CAPI Blocklist', value: d.capi_decisions || 0, type: (d.capi_decisions || 0) > 0 ? 'success' : '' },
{ label: 'Local Bans', value: d.local_decisions || 0, type: (d.local_decisions || 0) > 0 ? 'danger' : '' },
{ label: 'Active Bans', value: this.formatNumber(totalBans), type: totalBans > 0 ? 'success' : '' },
{ label: 'Blocked Packets', value: this.formatNumber(droppedPkts), type: droppedPkts > 0 ? 'danger' : '' },
{ label: 'Blocked Traffic', value: this.formatBytes(droppedBytes), type: droppedBytes > 0 ? 'danger' : '' },
{ label: 'Alerts (24h)', value: d.alerts_24h || 0, type: (d.alerts_24h || 0) > 10 ? 'warning' : '' },
{ label: 'Scenarios', value: d.scenario_count || 0, type: 'success' },
{ label: 'Bouncers', value: d.bouncer_count || 0, type: (d.bouncer_count || 0) > 0 ? 'success' : 'warning' },
{ label: 'Countries', value: Object.keys(d.countries || {}).length, type: '' }
{ label: 'Local Bans', value: d.local_decisions || 0, type: (d.local_decisions || 0) > 0 ? 'warning' : '' },
{ label: 'Bouncers', value: d.bouncer_count || 0, type: (d.bouncer_count || 0) > 0 ? 'success' : 'warning' }
];
return E('div', { 'class': 'soc-stats' }, stats.map(function(s) {
return E('div', { 'class': 'soc-stat ' + s.type }, [
@ -110,6 +110,19 @@ return view.extend({
}));
},
formatNumber: function(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
},
formatBytes: function(bytes) {
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1) + 'GB';
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + 'MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + 'KB';
return bytes + 'B';
},
renderAlerts: function(alerts) {
if (!alerts || !alerts.length) {
return E('div', { 'class': 'soc-empty' }, [
@ -202,6 +215,56 @@ return view.extend({
]);
},
renderThreatTypes: function(rawJson) {
var self = this;
var threats = [];
if (rawJson) {
try { threats = JSON.parse(rawJson); } catch(e) {}
}
if (!threats || !threats.length) {
return E('div', { 'class': 'soc-empty' }, [
E('div', { 'class': 'soc-empty-icon' }, '\u{1F6E1}'),
'No threats blocked yet'
]);
}
var total = threats.reduce(function(sum, t) { return sum + (t.count || 0); }, 0);
return E('div', { 'class': 'soc-threat-types' }, [
E('table', { 'class': 'soc-table' }, [
E('thead', {}, E('tr', {}, [
E('th', {}, 'Threat Type'),
E('th', {}, 'Blocked'),
E('th', { 'style': 'width:40%' }, 'Distribution')
])),
E('tbody', {}, threats.map(function(t) {
var pct = total > 0 ? Math.round((t.count / total) * 100) : 0;
var severity = t.scenario.includes('bruteforce') ? 'high' :
t.scenario.includes('exploit') ? 'critical' :
t.scenario.includes('scan') ? 'medium' : 'low';
return E('tr', {}, [
E('td', {}, [
E('span', { 'class': 'soc-threat-icon ' + severity }, self.getThreatIcon(t.scenario)),
E('span', { 'class': 'soc-scenario' }, t.scenario)
]),
E('td', { 'class': 'soc-threat-count' }, self.formatNumber(t.count)),
E('td', {}, E('div', { 'class': 'soc-bar-wrap' }, [
E('div', { 'class': 'soc-bar ' + severity, 'style': 'width:' + pct + '%' }),
E('span', { 'class': 'soc-bar-pct' }, pct + '%')
]))
]);
}))
]),
E('div', { 'class': 'soc-threat-total' }, 'Total blocked: ' + self.formatNumber(total))
]);
},
getThreatIcon: function(scenario) {
if (scenario.includes('bruteforce')) return '\u{1F510}';
if (scenario.includes('exploit')) return '\u{1F4A3}';
if (scenario.includes('scan')) return '\u{1F50D}';
if (scenario.includes('http')) return '\u{1F310}';
return '\u26A0';
},
pollData: function() {
var self = this;
return api.getOverview().then(function(data) {

View File

@ -285,17 +285,18 @@ get_dashboard_stats() {
bouncers_count=$(run_cscli bouncers list -o json 2>/dev/null | jsonfilter -e '@[*]' 2>/dev/null | wc -l)
json_add_int "bouncers" "${bouncers_count:-0}"
# Top scenarios (from alerts)
# Top scenarios (from cscli metrics - includes CAPI blocklist breakdown)
local scenarios
scenarios=$(run_cscli alerts list -o json --limit 100 2>/dev/null | \
jsonfilter -e '@[*].scenario' 2>/dev/null | \
sort | uniq -c | sort -rn | head -5 | \
scenarios=$(run_cscli metrics 2>/dev/null | \
grep -E '^\| [a-z].*\| CAPI' | \
sed 's/|//g;s/^[ ]*//;s/[ ]*$//' | \
awk '{print $4, $1}' | sort -rn | head -5 | \
awk '{print "{\"scenario\":\"" $2 "\",\"count\":" $1 "}"}' | \
tr '\n' ',' | sed 's/,$//')
json_add_string "top_scenarios_raw" "[$scenarios]"
# Top countries (from alerts - GeoIP enriched)
# Top countries (from alerts with GeoIP enrichment)
local countries
countries=$(run_cscli alerts list -o json --limit 500 2>/dev/null | \
jsonfilter -e '@[*].source.cn' 2>/dev/null | \
@ -2216,6 +2217,32 @@ get_overview() {
json_add_int "alerts_24h" "${alerts_count:-0}"
json_add_int "bouncer_count" "${bouncers_count:-0}"
# Bouncer effectiveness stats (packets/bytes dropped vs processed)
local dropped_packets=0
local dropped_bytes=0
local processed_packets=0
local processed_bytes=0
local active_bans=0
if [ "$cs_running" = "1" ]; then
# Parse Total line from Bouncer Metrics table
# Format: | Total | 16.00k | 13.72k | 231 | 356.19k | 6.02k |
local totals
totals=$(run_cscli metrics 2>/dev/null | grep -E '^\|.*Total' | sed 's/|//g')
if [ -n "$totals" ]; then
# Convert k/M suffixes to numbers
active_bans=$(echo "$totals" | awk '{gsub(/k$/,"",$2); gsub(/M$/,"",$2); if($2~/\./){$2=$2*1000}; print int($2)}')
dropped_bytes=$(echo "$totals" | awk '{v=$3; gsub(/k$/,"",v); gsub(/M$/,"",v); if(v~/\./){v=v*1000}; print v}')
dropped_packets=$(echo "$totals" | awk '{print $4}')
processed_bytes=$(echo "$totals" | awk '{v=$5; gsub(/k$/,"",v); gsub(/M$/,"",v); if(v~/\./){v=v*1000}; if(v=="-")v=0; print v}')
processed_packets=$(echo "$totals" | awk '{v=$6; gsub(/k$/,"",v); gsub(/M$/,"",v); if(v~/\./){v=v*1000}; if(v=="-")v=0; print v}')
fi
fi
json_add_int "active_bans" "${active_bans:-0}"
json_add_string "dropped_packets" "${dropped_packets:-0}"
json_add_string "dropped_bytes" "${dropped_bytes:-0}"
json_add_string "processed_packets" "${processed_packets:-0}"
json_add_string "processed_bytes" "${processed_bytes:-0}"
# GeoIP status - check if GeoIP database exists
local geoip_enabled=0
[ -f "/var/lib/crowdsec/data/GeoLite2-City.mmdb" ] && geoip_enabled=1
@ -2237,18 +2264,22 @@ get_overview() {
fi
json_add_int "scenario_count" "${scenario_count:-0}"
# Top scenarios (from cached/limited alerts)
# Top scenarios (from cscli metrics - includes CAPI blocklist breakdown)
local scenarios=""
if [ "$cs_running" = "1" ]; then
scenarios=$(run_cscli alerts list -o json --limit 100 2>/dev/null | \
jsonfilter -e '@[*].scenario' 2>/dev/null | \
sort | uniq -c | sort -rn | head -5 | \
# Parse "Local API Decisions" table from cscli metrics
# Lines like: | ssh:bruteforce | CAPI | ban | 12095 |
scenarios=$(run_cscli metrics 2>/dev/null | \
grep -E '^\| [a-z].*\| CAPI' | \
sed 's/|//g;s/^[ ]*//;s/[ ]*$//' | \
awk '{print $4, $1}' | sort -rn | head -5 | \
awk '{print "{\"scenario\":\"" $2 "\",\"count\":" $1 "}"}' | \
tr '\n' ',' | sed 's/,$//')
fi
json_add_string "top_scenarios_raw" "[$scenarios]"
# Top countries (from alerts with GeoIP)
# Top countries (from alerts with GeoIP enrichment)
# Note: CAPI decisions don't include country - only local detections have GeoIP
local countries=""
if [ "$cs_running" = "1" ]; then
countries=$(run_cscli alerts list -o json --limit 200 2>/dev/null | \