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:
parent
9de86dc854
commit
02bb26ad4d
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 | \
|
||||
|
||||
Loading…
Reference in New Issue
Block a user