From 2371318e9e4398c4fb2bb2056543c0e83c5a0c3d Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 28 May 2026 16:11:29 +0200 Subject: [PATCH] fix(heatmap): detection rules layer uses absolute rule count, not relative max MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: score = (rules/max_rules)*50 + (evaluated/rules)*50 -> everything red because relative to the 1 technique with most rules After: score = min(rules/4 * 100, 100) — absolute thresholds 0 rules = gray (not covered) 1 rule = red (25 — minimal) 2 rules = orange (50 — some) 3 rules = yellow (75 — good) 4+ rules = green (100 — well covered) Also update HeatmapLegend labels to show actual rule counts instead of meaningless percentage ranges. Co-Authored-By: Claude Sonnet 4.6 --- backend/app/services/heatmap_service.py | 30 ++++++++++++------- .../src/components/heatmap/HeatmapLegend.tsx | 10 +++---- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/backend/app/services/heatmap_service.py b/backend/app/services/heatmap_service.py index 1ac34cf..a6672d5 100644 --- a/backend/app/services/heatmap_service.py +++ b/backend/app/services/heatmap_service.py @@ -300,10 +300,19 @@ def build_detection_rules_layer( tactics: str | None = None, min_score: int = 0, ) -> dict: - """Detection rules layer -- score based on rule availability and evaluation ratio.""" + """Detection rules layer -- score based on absolute rule count per technique. + + Scoring uses fixed thresholds so the colour reflects real coverage regardless + of what other techniques have: + 0 rules → gray (score 0) + 1 rule → red (score 25) + 2 rules → orange (score 50) + 3 rules → yellow (score 75) + 4+ rules → green (score 100) + """ layer = _build_layer_skeleton( "Detection Rules Coverage", - "Coverage of detection rules per technique", + "Number of active detection rules per technique", ) query = _apply_filters( @@ -318,7 +327,6 @@ def build_detection_rules_layer( .group_by(DetectionRule.mitre_technique_id) .all() ) - max_rules = max(rule_counts.values()) if rule_counts else 1 evaluated_counts = dict( db.query(DetectionRule.mitre_technique_id, func.count(TestDetectionResult.id)) @@ -328,26 +336,28 @@ def build_detection_rules_layer( .all() ) + # 4 rules = full coverage (100). Each rule adds 25 points. + RULES_FOR_FULL_COVERAGE = 4 + for tech in techniques: total_rules = rule_counts.get(tech.mitre_id, 0) evaluated_rules = evaluated_counts.get(tech.mitre_id, 0) - if total_rules > 0: - availability_score = min((total_rules / max_rules) * 50, 50) - evaluation_score = (evaluated_rules / total_rules) * 50 - score = int(min(availability_score + evaluation_score, 100)) - else: - score = 0 + score = min(int((total_rules / RULES_FOR_FULL_COVERAGE) * 100), 100) if score < min_score: continue + rule_word = "rule" if total_rules == 1 else "rules" + eval_note = f", {evaluated_rules} evaluated" if evaluated_rules > 0 else "" + comment = f"{total_rules} active {rule_word}{eval_note}" + layer["techniques"].append({ "techniqueID": tech.mitre_id, "tactic": _format_tactic(tech.tactic), "color": _score_to_color(score), "score": score, - "comment": f"{total_rules} rules available, {evaluated_rules} evaluated", + "comment": comment, "enabled": True, "metadata": [ {"name": "total_rules", "value": str(total_rules)}, diff --git a/frontend/src/components/heatmap/HeatmapLegend.tsx b/frontend/src/components/heatmap/HeatmapLegend.tsx index d91bbab..738ff1b 100644 --- a/frontend/src/components/heatmap/HeatmapLegend.tsx +++ b/frontend/src/components/heatmap/HeatmapLegend.tsx @@ -29,11 +29,11 @@ const LEGENDS: Record< "detection-rules": { label: "Detection Rules Coverage", colors: [ - { color: "#d3d3d3", label: "No Rules (0)" }, - { color: "#ff6666", label: "Few Rules (<25)" }, - { color: "#ff9933", label: "Some Rules (25-50)" }, - { color: "#ffff66", label: "Good Coverage (50-75)" }, - { color: "#66ff66", label: "Full Coverage (75-100)" }, + { color: "#d3d3d3", label: "0 rules" }, + { color: "#ff6666", label: "1 rule" }, + { color: "#ff9933", label: "2 rules" }, + { color: "#ffff66", label: "3 rules" }, + { color: "#66ff66", label: "4+ rules" }, ], }, campaign: {