mirror of
https://github.com/marcredhat/SIEM-toolkit-patched
synced 2026-06-08 20:37:12 +00:00
Add product grouping to rule displays across coverage and threat pages
- Extract product label from rule data_sources in coverage.py via new _product_from_data_sources() helper (prefers non-SentinelOne entries so product-specific rules get a meaningful label) - Coverage Map detections column: rules now grouped by product with collapsible chevron headers showing fired/silent counts - Threat Coverage Rule Firing Status: collapsible product group headers with active/silent summary; shows all 2066 rules across 30 products - Threat Coverage Dependency Map: collapsible product groups, at-risk products sorted first with risk count in header - Ingest Dashboard: fix source name truncation — table cells now wrap with break-all and title tooltip; bar chart labels extended to 16 chars with ellipsis and full-name tooltip on hover Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+164
-54
@@ -104,14 +104,17 @@ function barChart(rows, labelKey, valueKey) {
|
||||
const bh = Math.max(2, Math.floor((val / max) * chartH))
|
||||
const x = padL + i * (chartW / rows.length) + (chartW / rows.length - bw) / 2
|
||||
const y = padT + chartH - bh
|
||||
// If label looks like a date (YYYY-MM-DD), show MM/DD; otherwise truncate to 10 chars
|
||||
// If label looks like a date (YYYY-MM-DD), show MM/DD; otherwise truncate to 16 chars
|
||||
const rawLbl = String(r[labelKey] || '')
|
||||
const lbl = esc(/^\d{4}-\d{2}-\d{2}$/.test(rawLbl) ? rawLbl.slice(5, 10) : rawLbl.slice(0, 10))
|
||||
const isDate = /^\d{4}-\d{2}-\d{2}$/.test(rawLbl)
|
||||
const lbl = esc(isDate ? rawLbl.slice(5, 10) : (rawLbl.length > 16 ? rawLbl.slice(0, 15) + '…' : rawLbl))
|
||||
// value label on top of bar
|
||||
const valLbl = val >= 1000 ? (val/1000).toFixed(1)+'k' : val
|
||||
return `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" fill="url(#barGrad)" rx="2"/>
|
||||
return `<g><title>${esc(rawLbl)}: ${val.toLocaleString()}</title>
|
||||
<rect x="${x}" y="${y}" width="${bw}" height="${bh}" fill="url(#barGrad)" rx="2"/>
|
||||
<text x="${x + bw/2}" y="${y - 4}" text-anchor="middle" fill="#c084fc" font-size="9" font-weight="500">${valLbl}</text>
|
||||
<text x="${x + bw/2}" y="${H - 6}" text-anchor="middle" fill="#6b7280" font-size="9">${lbl}</text>`
|
||||
<text x="${x + bw/2}" y="${H - 6}" text-anchor="middle" fill="#6b7280" font-size="9">${lbl}</text>
|
||||
</g>`
|
||||
}).join('')
|
||||
|
||||
return `<svg viewBox="0 0 ${W} ${H}" class="w-full">${defs}${ticks}${bars}</svg>`
|
||||
@@ -654,15 +657,62 @@ function cvSetFilter(f) {
|
||||
const firingPopulated = cvData?.summary?.firing_cache_populated === true
|
||||
if (s.rule_count) {
|
||||
if (firingPopulated) {
|
||||
const ruleItems = (s.rules || []).map(r => {
|
||||
const alerts = r.alert_count || 0
|
||||
if (alerts > 0) {
|
||||
return `<span class="text-purple-400 font-mono text-xs" title="${esc(r.rule)} — ${alerts} alerts">${esc(r.rule.length > 40 ? r.rule.slice(0, 40) + '…' : r.rule)} <span class="text-emerald-500">(${alerts})</span></span>`
|
||||
} else {
|
||||
return `<span class="text-amber-400 font-mono text-xs" title="${esc(r.rule)} — no alerts in period">⚠ ${esc(r.rule.length > 40 ? r.rule.slice(0, 40) + '…' : r.rule)}</span>`
|
||||
}
|
||||
const rules = s.rules || []
|
||||
// Group rules by product label
|
||||
const groups = {}
|
||||
for (const r of rules) {
|
||||
const prod = r.product || 'SentinelOne'
|
||||
if (!groups[prod]) groups[prod] = []
|
||||
groups[prod].push(r)
|
||||
}
|
||||
const prodNames = Object.keys(groups).sort((a, b) => {
|
||||
// Put SentinelOne last (it's the generic catch-all)
|
||||
if (a === 'SentinelOne') return 1
|
||||
if (b === 'SentinelOne') return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
return `<div class="space-y-0.5">${ruleItems.join('')}</div>`
|
||||
const groupHtml = prodNames.map(prod => {
|
||||
const prodRules = groups[prod]
|
||||
const fired = prodRules.filter(r => (r.alert_count || 0) > 0).length
|
||||
const headerColor = fired === prodRules.length ? 'text-emerald-400' : fired > 0 ? 'text-amber-400' : 'text-slate-400'
|
||||
const uid = 'dg_' + Math.random().toString(36).slice(2)
|
||||
const ruleItems = prodRules.map(r => {
|
||||
const alerts = r.alert_count || 0
|
||||
const label = r.rule.length > 50 ? r.rule.slice(0, 50) + '…' : r.rule
|
||||
if (alerts > 0) {
|
||||
return `<div class="text-purple-300 font-mono text-xs leading-5 pl-2 border-l border-white/10" title="${esc(r.rule)} — ${alerts} alerts">${esc(label)} <span class="text-emerald-500">(${alerts})</span></div>`
|
||||
} else {
|
||||
return `<div class="text-amber-400 font-mono text-xs leading-5 pl-2 border-l border-white/10" title="${esc(r.rule)} — no alerts in period">⚠ ${esc(label)}</div>`
|
||||
}
|
||||
}).join('')
|
||||
return `<div class="mb-1">
|
||||
<button onclick="this.nextElementSibling.classList.toggle('hidden')" class="flex items-center gap-1 text-xs font-medium ${headerColor} hover:opacity-80 transition-opacity w-full text-left">
|
||||
<svg class="w-3 h-3 shrink-0 opacity-60" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
${esc(prod)} <span class="text-slate-500 font-normal ml-1">${prodRules.length} rule${prodRules.length !== 1 ? 's' : ''}</span>
|
||||
</button>
|
||||
<div class="hidden mt-0.5 space-y-0.5">${ruleItems}</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
return `<div class="text-xs">${groupHtml}</div>`
|
||||
}
|
||||
// Firing cache not populated — show grouped product summary (no expand)
|
||||
const rules = s.rules || []
|
||||
if (rules.length > 0) {
|
||||
const groups = {}
|
||||
for (const r of rules) {
|
||||
const prod = r.product || 'SentinelOne'
|
||||
groups[prod] = (groups[prod] || 0) + 1
|
||||
}
|
||||
const prodNames = Object.keys(groups).sort((a, b) => {
|
||||
if (a === 'SentinelOne') return 1
|
||||
if (b === 'SentinelOne') return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
if (prodNames.length === 1) {
|
||||
return `<span class="text-purple-400 font-medium">${s.rule_count}</span> <span class="text-slate-500 text-xs">rules · ${esc(prodNames[0])}</span>`
|
||||
}
|
||||
const breakdown = prodNames.map(p => `<span class="text-slate-400">${esc(p)}: ${groups[p]}</span>`).join('<br>')
|
||||
return `<span class="text-purple-400 font-medium">${s.rule_count}</span> <span class="text-slate-500 text-xs">rules</span><br><span class="text-xs text-slate-600">${breakdown}</span>`
|
||||
}
|
||||
return `<span class="text-purple-400 font-medium">${s.rule_count}</span> rule${s.rule_count !== 1 ? 's' : ''}`
|
||||
}
|
||||
@@ -853,17 +903,17 @@ async function igLoad() {
|
||||
const name = r['dataSource.name'] || r.name || 'unknown'
|
||||
const evts = r.events || 0
|
||||
return `<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
|
||||
<td class="py-2.5 px-1 font-mono text-xs text-gray-200">${esc(name)}</td>
|
||||
<td class="py-2.5 px-1 text-right text-gray-300 tabular-nums">${evts.toLocaleString()}</td>
|
||||
<td class="py-2.5 px-1 text-right text-slate-400 tabular-nums">${(evts/1e6*0.5).toFixed(3)}</td>
|
||||
<td class="py-2.5 px-3 font-mono text-xs text-gray-200 break-all" title="${esc(name)}">${esc(name)}</td>
|
||||
<td class="py-2.5 px-3 text-right text-gray-300 tabular-nums whitespace-nowrap">${evts.toLocaleString()}</td>
|
||||
<td class="py-2.5 px-3 text-right text-slate-400 tabular-nums whitespace-nowrap">${(evts/1e6*0.5).toFixed(3)}</td>
|
||||
</tr>`
|
||||
})
|
||||
document.getElementById('ig-sources').innerHTML = rows.length ? `
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-slate-500 border-b border-white/5">
|
||||
<th class="pb-2.5 font-medium text-xs uppercase tracking-wide">Source</th>
|
||||
<th class="pb-2.5 text-right font-medium text-xs uppercase tracking-wide">Events</th>
|
||||
<th class="pb-2.5 text-right font-medium text-xs uppercase tracking-wide">Est. GB</th>
|
||||
<th class="pb-2.5 px-3 font-medium text-xs uppercase tracking-wide">Source</th>
|
||||
<th class="pb-2.5 px-3 text-right font-medium text-xs uppercase tracking-wide whitespace-nowrap">Events</th>
|
||||
<th class="pb-2.5 px-3 text-right font-medium text-xs uppercase tracking-wide whitespace-nowrap">Est. GB</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.join('')}</tbody>
|
||||
</table>` : `<p class="text-slate-500 text-sm">No data in this period.</p>`
|
||||
@@ -2056,30 +2106,57 @@ async function loadFiringStatus() {
|
||||
${statCard('Never Fired', s.never_fired, s.never_fired > 0 ? 'text-amber-400' : 'text-gray-500')}
|
||||
</div>`
|
||||
|
||||
const top20 = data.rules.slice(0, 20)
|
||||
const rows = top20.map((r, i) => {
|
||||
const badge = r.alert_count > 0
|
||||
? `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-emerald-900/50 text-emerald-300 border-emerald-700">Active</span>`
|
||||
: `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-amber-900/50 text-amber-300 border-amber-700">Silent</span>`
|
||||
return `<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
|
||||
<td class="py-2.5 px-4 font-mono text-xs text-gray-200">${esc(r.rule_name)}</td>
|
||||
<td class="py-2.5 px-4 text-xs text-slate-300 tabular-nums">${r.alert_count.toLocaleString()}</td>
|
||||
<td class="py-2.5 px-4">${badge}</td>
|
||||
</tr>`
|
||||
// Group rules by product
|
||||
const firingGroups = {}
|
||||
for (const r of data.rules) {
|
||||
const prod = r.product || 'SentinelOne'
|
||||
if (!firingGroups[prod]) firingGroups[prod] = []
|
||||
firingGroups[prod].push(r)
|
||||
}
|
||||
const firingProdNames = Object.keys(firingGroups).sort((a, b) => {
|
||||
if (a === 'SentinelOne') return 1
|
||||
if (b === 'SentinelOne') return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
const groupSections = firingProdNames.map(prod => {
|
||||
const rules = firingGroups[prod]
|
||||
const firedCount = rules.filter(r => r.alert_count > 0).length
|
||||
const headerColor = firedCount === rules.length ? 'text-emerald-400' : firedCount > 0 ? 'text-amber-400' : 'text-slate-400'
|
||||
const uid = 'fg_' + prod.replace(/[^a-z0-9]/gi, '_')
|
||||
const rows = rules.map((r, i) => {
|
||||
const badge = r.alert_count > 0
|
||||
? `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-emerald-900/50 text-emerald-300 border-emerald-700">Active</span>`
|
||||
: `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-amber-900/50 text-amber-300 border-amber-700">Silent</span>`
|
||||
return `<tr class="${i % 2 === 1 ? 'bg-white/[0.015]' : ''} border-b border-white/5 hover:bg-white/[0.04] transition-colors">
|
||||
<td class="py-2.5 px-4 font-mono text-xs text-gray-200">${esc(r.rule_name)}</td>
|
||||
<td class="py-2.5 px-4 text-xs text-slate-300 tabular-nums">${r.alert_count.toLocaleString()}</td>
|
||||
<td class="py-2.5 px-4">${badge}</td>
|
||||
</tr>`
|
||||
}).join('')
|
||||
return `<div class="mb-3">
|
||||
<button onclick="document.getElementById('${uid}').classList.toggle('hidden')"
|
||||
class="w-full flex items-center gap-2 px-4 py-2.5 bg-slate-900/80 hover:bg-slate-900 border border-white/5 rounded-t-xl text-left transition-colors">
|
||||
<svg class="w-3.5 h-3.5 text-slate-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
<span class="font-medium text-sm ${headerColor}">${esc(prod)}</span>
|
||||
<span class="text-slate-500 text-xs">${rules.length} rule${rules.length !== 1 ? 's' : ''}</span>
|
||||
<span class="ml-auto text-xs ${firedCount > 0 ? 'text-emerald-500' : 'text-slate-600'}">${firedCount} active · ${rules.length - firedCount} silent</span>
|
||||
</button>
|
||||
<div id="${uid}" class="hidden overflow-x-auto ring-1 ring-white/5 rounded-b-xl">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-slate-500 bg-slate-900/60 border-b border-white/5">
|
||||
<th class="py-2.5 px-4 font-medium text-xs uppercase tracking-wide">Rule Name</th>
|
||||
<th class="py-2.5 px-4 font-medium text-xs uppercase tracking-wide">Alerts (${s.period_days}d)</th>
|
||||
<th class="py-2.5 px-4 font-medium text-xs uppercase tracking-wide">Status</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
tableEl.innerHTML = `
|
||||
<div class="overflow-x-auto rounded-xl ring-1 ring-white/5">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-slate-500 bg-slate-900/60 border-b border-white/5">
|
||||
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Rule Name</th>
|
||||
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Alerts (${s.period_days}d)</th>
|
||||
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Status</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${s.checked_at ? `<p class="text-xs text-slate-600 mt-2">Last synced: ${new Date(s.checked_at).toLocaleString()}</p>` : ''}`
|
||||
tableEl.innerHTML = groupSections +
|
||||
(s.checked_at ? `<p class="text-xs text-slate-600 mt-2">Last synced: ${new Date(s.checked_at).toLocaleString()}</p>` : '')
|
||||
} catch(e) {
|
||||
if (tableEl) tableEl.innerHTML = `<p class="text-red-400 text-sm">${esc(e.message)}</p>`
|
||||
}
|
||||
@@ -2129,7 +2206,24 @@ function depMapRender() {
|
||||
no_parser:'bg-amber-900/50 text-amber-300 border-amber-700',
|
||||
}
|
||||
|
||||
const rows = display.map((r, i) => {
|
||||
// Group by product
|
||||
const depGroups = {}
|
||||
for (const r of display) {
|
||||
const prod = r.product || 'SentinelOne'
|
||||
if (!depGroups[prod]) depGroups[prod] = []
|
||||
depGroups[prod].push(r)
|
||||
}
|
||||
const depProdNames = Object.keys(depGroups).sort((a, b) => {
|
||||
// At-risk products first, then alphabetical, SentinelOne last
|
||||
const aRisk = depGroups[a].some(r => r.at_risk)
|
||||
const bRisk = depGroups[b].some(r => r.at_risk)
|
||||
if (aRisk !== bRisk) return aRisk ? -1 : 1
|
||||
if (a === 'SentinelOne') return 1
|
||||
if (b === 'SentinelOne') return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
const makeDepRows = (groupRules) => groupRules.map((r, i) => {
|
||||
const statusBadge = r.at_risk
|
||||
? `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-red-900/50 text-red-300 border-red-700">⚠ At Risk</span>`
|
||||
: `<span class="px-2 py-0.5 rounded-full text-xs font-medium border bg-emerald-900/50 text-emerald-300 border-emerald-700">✓ Covered</span>`
|
||||
@@ -2152,19 +2246,35 @@ function depMapRender() {
|
||||
</tr>`
|
||||
}).join('')
|
||||
|
||||
tableEl.innerHTML = `
|
||||
<div class="overflow-x-auto rounded-xl ring-1 ring-white/5">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-slate-500 bg-slate-900/60 border-b border-white/5">
|
||||
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Rule Name</th>
|
||||
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Required Sources</th>
|
||||
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Status</th>
|
||||
<th class="py-3 px-4 font-medium text-xs uppercase tracking-wide">Alerts</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-slate-600 mt-2">Source badges: <span class="text-emerald-400">green</span> = healthy · <span class="text-red-400">red</span> = inactive · <span class="text-amber-400">amber</span> = no parser</p>`
|
||||
const groupedHtml = depProdNames.map(prod => {
|
||||
const groupRules = depGroups[prod]
|
||||
const atRiskCount = groupRules.filter(r => r.at_risk).length
|
||||
const headerColor = atRiskCount > 0 ? 'text-red-400' : 'text-emerald-400'
|
||||
const uid = 'dep_' + prod.replace(/[^a-z0-9]/gi, '_')
|
||||
return `<div class="mb-3">
|
||||
<button onclick="document.getElementById('${uid}').classList.toggle('hidden')"
|
||||
class="w-full flex items-center gap-2 px-4 py-2.5 bg-slate-900/80 hover:bg-slate-900 border border-white/5 rounded-t-xl text-left transition-colors">
|
||||
<svg class="w-3.5 h-3.5 text-slate-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
<span class="font-medium text-sm ${headerColor}">${esc(prod)}</span>
|
||||
<span class="text-slate-500 text-xs">${groupRules.length} rule${groupRules.length !== 1 ? 's' : ''}</span>
|
||||
${atRiskCount > 0 ? `<span class="ml-auto text-xs text-red-400">⚠ ${atRiskCount} at risk</span>` : `<span class="ml-auto text-xs text-emerald-500">✓ all covered</span>`}
|
||||
</button>
|
||||
<div id="${uid}" class="hidden overflow-x-auto ring-1 ring-white/5 rounded-b-xl">
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-slate-500 bg-slate-900/60 border-b border-white/5">
|
||||
<th class="py-2.5 px-4 font-medium text-xs uppercase tracking-wide">Rule</th>
|
||||
<th class="py-2.5 px-4 font-medium text-xs uppercase tracking-wide">Required Sources</th>
|
||||
<th class="py-2.5 px-4 font-medium text-xs uppercase tracking-wide">Status</th>
|
||||
<th class="py-2.5 px-4 font-medium text-xs uppercase tracking-wide">Alerts</th>
|
||||
</tr></thead>
|
||||
<tbody>${makeDepRows(groupRules)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
tableEl.innerHTML = groupedHtml +
|
||||
`<p class="text-xs text-slate-600 mt-2">Source badges: <span class="text-emerald-400">green</span> = healthy · <span class="text-red-400">red</span> = inactive · <span class="text-amber-400">amber</span> = no parser</p>`
|
||||
}
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user