From 7620d1fcc8c3c5290a58b1224a764f1737540bf0 Mon Sep 17 00:00:00 2001 From: Mick <119439091+mickbrowns1@users.noreply.github.com> Date: Fri, 22 May 2026 11:56:27 -0400 Subject: [PATCH] Add product grouping to rule displays across coverage and threat pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/routers/coverage.py | 29 ++++- frontend/index.html | 218 +++++++++++++++++++++++++++--------- 2 files changed, 192 insertions(+), 55 deletions(-) diff --git a/backend/routers/coverage.py b/backend/routers/coverage.py index 29766bd..96479ef 100644 --- a/backend/routers/coverage.py +++ b/backend/routers/coverage.py @@ -81,6 +81,17 @@ def _extract_mitre(rule: dict) -> tuple[list[str], list[dict]]: return list(dict.fromkeys(tactics)), unique_techniques +def _product_from_data_sources(data_sources: list) -> str: + """Derive a product label from a rule's data_sources list. + Prefers the first non-SentinelOne entry (e.g. 'AWS CloudTrail', 'Okta'), + falls back to 'SentinelOne' for generic endpoint rules. + """ + if not data_sources: + return "SentinelOne" + non_s1 = [d for d in data_sources if d.lower() not in ("sentinelone", "s1")] + return non_s1[0] if non_s1 else data_sources[0] + + def _star_query_texts(rule: dict) -> list[str]: """ Extract all PowerQuery/filter strings from a STAR rule. @@ -732,8 +743,10 @@ def get_coverage_map(db: Session = Depends(get_db)): query_texts = _star_query_texts(raw_data) data_sources = rule_parser.extract_data_sources(query_texts) + product = _product_from_data_sources(data_sources) + for ds in data_sources: - rule_by_source.setdefault(ds, []).append({"rule": rule.name, "type": rule.rule_type}) + rule_by_source.setdefault(ds, []).append({"rule": rule.name, "type": rule.rule_type, "product": product}) # Fields to ignore when computing "missing" — these are metadata/schema fields # always present in events regardless of the parser @@ -789,6 +802,8 @@ def get_coverage_map(db: Session = Depends(get_db)): for r in rule_by_source.get(src.source_name, []) if r["type"] == "library" ] + # Sort rules so grouped-by-product rendering is stable + rules_for_src.sort(key=lambda r: (r.get("product", ""), r["rule"])) # Close-match suggestions — shown when there are no library rules for this source. close_matches: list = [] @@ -1062,6 +1077,16 @@ def get_rule_firing_cache(db: Session = Depends(get_db)): never_fired_count = total_rules - len(fired) period_days = rows[0].period_days if rows else 30 checked_at = rows[0].checked_at.isoformat() if rows and rows[0].checked_at else None + + # Build rule_name → product lookup from ParsedRule raw JSON + rule_product: dict[str, str] = {} + for rule in db.query(ParsedRule).filter_by(rule_type="library").all(): + try: + raw_data = json.loads(rule.raw) if rule.raw else {} + except Exception: + raw_data = {} + rule_product[rule.name] = _product_from_data_sources(raw_data.get("data_sources", [])) + return { "rules": [ { @@ -1069,6 +1094,7 @@ def get_rule_firing_cache(db: Session = Depends(get_db)): "alert_count": r.alert_count, "period_days": r.period_days, "checked_at": r.checked_at.isoformat() if r.checked_at else None, + "product": rule_product.get(r.rule_name, "SentinelOne"), } for r in rows ], @@ -1279,6 +1305,7 @@ def get_dependency_map(db: Session = Depends(get_db)): "generated_alerts": generated_alerts, "at_risk": at_risk, "no_sources": len(data_sources) == 0, + "product": _product_from_data_sources(data_sources), }) # Sort: at-risk first, then by source count desc, then alphabetical diff --git a/frontend/index.html b/frontend/index.html index 15f65f1..5443e06 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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 ` + return `${esc(rawLbl)}: ${val.toLocaleString()} + ${valLbl} - ${lbl}` + ${lbl} + ` }).join('') return `${defs}${ticks}${bars}` @@ -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 `${esc(r.rule.length > 40 ? r.rule.slice(0, 40) + '…' : r.rule)} (${alerts})` - } else { - return `⚠ ${esc(r.rule.length > 40 ? r.rule.slice(0, 40) + '…' : r.rule)}` - } + 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 `
${ruleItems.join('')}
` + 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 `
${esc(label)} (${alerts})
` + } else { + return `
⚠ ${esc(label)}
` + } + }).join('') + return `
+ + +
` + }).join('') + return `
${groupHtml}
` + } + // 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 `${s.rule_count} rules · ${esc(prodNames[0])}` + } + const breakdown = prodNames.map(p => `${esc(p)}: ${groups[p]}`).join('
') + return `${s.rule_count} rules
${breakdown}` } return `${s.rule_count} 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 ` - ${esc(name)} - ${evts.toLocaleString()} - ${(evts/1e6*0.5).toFixed(3)} + ${esc(name)} + ${evts.toLocaleString()} + ${(evts/1e6*0.5).toFixed(3)} ` }) document.getElementById('ig-sources').innerHTML = rows.length ? ` - - - + + + ${rows.join('')}
SourceEventsEst. GBSourceEventsEst. GB
` : `

No data in this period.

` @@ -2056,30 +2106,57 @@ async function loadFiringStatus() { ${statCard('Never Fired', s.never_fired, s.never_fired > 0 ? 'text-amber-400' : 'text-gray-500')} ` - const top20 = data.rules.slice(0, 20) - const rows = top20.map((r, i) => { - const badge = r.alert_count > 0 - ? `Active` - : `Silent` - return ` - ${esc(r.rule_name)} - ${r.alert_count.toLocaleString()} - ${badge} - ` + // 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 + ? `Active` + : `Silent` + return ` + ${esc(r.rule_name)} + ${r.alert_count.toLocaleString()} + ${badge} + ` + }).join('') + return `
+ + +
` }).join('') - tableEl.innerHTML = ` -
- - - - - - - ${rows} -
Rule NameAlerts (${s.period_days}d)Status
-
- ${s.checked_at ? `

Last synced: ${new Date(s.checked_at).toLocaleString()}

` : ''}` + tableEl.innerHTML = groupSections + + (s.checked_at ? `

Last synced: ${new Date(s.checked_at).toLocaleString()}

` : '') } catch(e) { if (tableEl) tableEl.innerHTML = `

${esc(e.message)}

` } @@ -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 ? `⚠ At Risk` : `✓ Covered` @@ -2152,19 +2246,35 @@ function depMapRender() { ` }).join('') - tableEl.innerHTML = ` -
- - - - - - - - ${rows} -
Rule NameRequired SourcesStatusAlerts
-
-

Source badges: green = healthy · red = inactive · amber = no parser

` + 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 `
+ + +
` + }).join('') + + tableEl.innerHTML = groupedHtml + + `

Source badges: green = healthy · red = inactive · amber = no parser

` } // ── Router ────────────────────────────────────────────────────────────────