diff --git a/backend/routers/coverage.py b/backend/routers/coverage.py index 29766bd..64d1e5d 100644 --- a/backend/routers/coverage.py +++ b/backend/routers/coverage.py @@ -1098,7 +1098,21 @@ def _compute_health(db) -> dict: parser_pct = round((covered_sources / total_sources * 100) if total_sources else 0.0, 1) # --- MITRE coverage --- - TOTAL_TACTICS = 14 # standard ATT&CK Enterprise tactic count + # Standard ATT&CK Enterprise tactics (14). + CANONICAL_TACTICS = frozenset({ + "Reconnaissance", "Resource Development", "Initial Access", "Execution", + "Persistence", "Privilege Escalation", "Defense Evasion", "Credential Access", + "Discovery", "Lateral Movement", "Collection", "Command and Control", + "Exfiltration", "Impact", + }) + # SentinelOne STAR rules sometimes label tactics with non-canonical names. + # Map them to canonical ATT&CK so we don't over-count and exceed 100%. + TACTIC_ALIASES = { + "Stealth": "Defense Evasion", + "Defense Impairment": "Defense Evasion", + } + TOTAL_TACTICS = len(CANONICAL_TACTICS) + rules = db.query(ParsedRule).filter_by(rule_type="library").all() total_rules = len(rules) covered_tactics: set = set() @@ -1114,7 +1128,10 @@ def _compute_health(db) -> dict: if tactics or techniques: rules_with_mitre += 1 for t in tactics: - if t and t != "Uncategorized": + if not t or t == "Uncategorized": + continue + t = TACTIC_ALIASES.get(t, t) + if t in CANONICAL_TACTICS: covered_tactics.add(t) for tech in techniques: k = tech.get("id") or tech.get("name")