aws-cis-controls-assessment 1.0.7__py3-none-any.whl → 1.0.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,44 @@ class HTMLReporter(ReportGenerator):
21
21
  Generates interactive web-based reports with executive dashboard,
22
22
  compliance summaries, charts, detailed drill-down capabilities,
23
23
  and responsive design for mobile and desktop viewing.
24
+
25
+ Features:
26
+ - Executive dashboard with key compliance metrics
27
+ - Implementation Groups section showing unique controls per IG
28
+ - Control display names combining control ID and AWS Config rule name
29
+ - IG membership badges indicating which IGs include each control
30
+ - Consolidated detailed findings (deduplicated across IGs)
31
+ - Interactive charts and collapsible sections
32
+ - Resource details with filtering and export capabilities
33
+ - Responsive design for mobile and desktop
34
+ - Print-friendly layout
35
+
36
+ Display Format Examples:
37
+ Control cards show formatted names like:
38
+ - "1.5: root-account-hardware-mfa-enabled"
39
+ - "2.1: IAM Password Policy (iam-password-policy)"
40
+
41
+ IG badges indicate membership:
42
+ - Blue badge (IG1) for foundational controls
43
+ - Green badge (IG2) for enhanced security controls
44
+ - Purple badge (IG3) for advanced security controls
45
+
46
+ CSS Classes for Custom Styling:
47
+ - .ig-badge-1: Blue badge for IG1 controls
48
+ - .ig-badge-2: Green badge for IG2 controls
49
+ - .ig-badge-3: Purple badge for IG3 controls
50
+ - .control-display-name: Formatted control name display
51
+ - .control-display-name.truncated: Truncated names with tooltip
52
+ - .ig-membership-badges: Container for IG membership badges
53
+ - .ig-membership-badge: Individual IG badge element
54
+ - .ig-explanation: Informational box explaining IG cumulative nature
55
+ - .ig-scope: Scope description for each IG section
56
+
57
+ Backward Compatibility:
58
+ - Works with existing AssessmentResult data structures
59
+ - Gracefully falls back to control ID only if config_rule_name is missing
60
+ - Preserves all existing sections and functionality
61
+ - Maintains existing CSS classes for compatibility
24
62
  """
25
63
 
26
64
  def __init__(self, template_dir: Optional[str] = None, include_charts: bool = True):
@@ -147,9 +185,19 @@ class HTMLReporter(ReportGenerator):
147
185
  control_data["progress_width"] = control_data["compliance_percentage"]
148
186
  control_data["severity_badge"] = self._get_severity_badge(control_data)
149
187
 
188
+ # Enrich control metadata with display name, IG badges, etc.
189
+ enriched_control = self._enrich_control_metadata(
190
+ control_data,
191
+ control_id,
192
+ ig_name,
193
+ html_data["implementation_groups"]
194
+ )
195
+ # Update control_data with enriched metadata
196
+ ig_data["controls"][control_id] = enriched_control
197
+
150
198
  # Process findings for display
151
- control_data["display_findings"] = self._prepare_findings_for_display(
152
- control_data.get("non_compliant_findings", [])
199
+ enriched_control["display_findings"] = self._prepare_findings_for_display(
200
+ enriched_control.get("non_compliant_findings", [])
153
201
  )
154
202
 
155
203
  # Enhance remediation priorities with visual elements
@@ -505,6 +553,58 @@ class HTMLReporter(ReportGenerator):
505
553
  margin-bottom: 10px;
506
554
  }
507
555
 
556
+ /* Control display name styles */
557
+ .control-display-name {
558
+ font-weight: 600;
559
+ color: #2c3e50;
560
+ margin-bottom: 5px;
561
+ font-size: 0.95em;
562
+ }
563
+
564
+ .control-display-name.truncated {
565
+ overflow: hidden;
566
+ text-overflow: ellipsis;
567
+ white-space: nowrap;
568
+ cursor: help;
569
+ }
570
+
571
+ /* IG membership badges */
572
+ .ig-membership-badges {
573
+ display: flex;
574
+ gap: 5px;
575
+ margin-top: 5px;
576
+ margin-bottom: 10px;
577
+ }
578
+
579
+ .ig-membership-badge {
580
+ font-size: 0.7em;
581
+ padding: 2px 6px;
582
+ border-radius: 10px;
583
+ font-weight: bold;
584
+ text-transform: uppercase;
585
+ letter-spacing: 0.5px;
586
+ }
587
+
588
+ .ig-badge-1 {
589
+ background-color: #3498db;
590
+ color: white;
591
+ } /* Blue for IG1 */
592
+
593
+ .ig-badge-2 {
594
+ background-color: #27ae60;
595
+ color: white;
596
+ } /* Green for IG2 */
597
+
598
+ .ig-badge-3 {
599
+ background-color: #9b59b6;
600
+ color: white;
601
+ } /* Purple for IG3 */
602
+
603
+ .ig-badge-default {
604
+ background-color: #95a5a6;
605
+ color: white;
606
+ }
607
+
508
608
  /* Badges */
509
609
  .badge {
510
610
  padding: 4px 8px;
@@ -862,6 +962,171 @@ class HTMLReporter(ReportGenerator):
862
962
  }
863
963
  }
864
964
 
965
+ /* Score Comparison Styles */
966
+ .score-comparison-section {
967
+ background: white;
968
+ border-radius: 8px;
969
+ padding: 25px;
970
+ margin-bottom: 30px;
971
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
972
+ }
973
+
974
+ .comparison-grid {
975
+ display: grid;
976
+ grid-template-columns: 1fr 1fr;
977
+ gap: 20px;
978
+ margin-bottom: 20px;
979
+ }
980
+
981
+ .comparison-card {
982
+ background: #f8f9fa;
983
+ border-radius: 8px;
984
+ padding: 20px;
985
+ border: 2px solid #e9ecef;
986
+ }
987
+
988
+ .comparison-card h4 {
989
+ margin: 0 0 15px 0;
990
+ color: #2c3e50;
991
+ font-size: 1.1em;
992
+ }
993
+
994
+ .comparison-value {
995
+ font-size: 2.5em;
996
+ font-weight: bold;
997
+ margin: 10px 0;
998
+ }
999
+
1000
+ .comparison-card.weighted .comparison-value {
1001
+ color: #3498db;
1002
+ }
1003
+
1004
+ .comparison-card.aws-config .comparison-value {
1005
+ color: #9b59b6;
1006
+ }
1007
+
1008
+ .comparison-features {
1009
+ list-style: none;
1010
+ padding: 0;
1011
+ margin: 15px 0 0 0;
1012
+ }
1013
+
1014
+ .comparison-features li {
1015
+ padding: 5px 0;
1016
+ color: #666;
1017
+ font-size: 0.9em;
1018
+ }
1019
+
1020
+ .comparison-features li:before {
1021
+ content: "✓ ";
1022
+ color: #27ae60;
1023
+ font-weight: bold;
1024
+ margin-right: 5px;
1025
+ }
1026
+
1027
+ .score-difference {
1028
+ background: #fff3cd;
1029
+ border: 1px solid #ffc107;
1030
+ border-radius: 8px;
1031
+ padding: 15px;
1032
+ margin-top: 20px;
1033
+ display: flex;
1034
+ align-items: center;
1035
+ gap: 15px;
1036
+ }
1037
+
1038
+ .diff-icon {
1039
+ font-size: 2em;
1040
+ flex-shrink: 0;
1041
+ }
1042
+
1043
+ .diff-content {
1044
+ flex-grow: 1;
1045
+ }
1046
+
1047
+ .diff-content strong {
1048
+ display: block;
1049
+ margin-bottom: 5px;
1050
+ color: #856404;
1051
+ }
1052
+
1053
+ .diff-content p {
1054
+ margin: 0;
1055
+ color: #856404;
1056
+ font-size: 0.9em;
1057
+ }
1058
+
1059
+ .score-difference.neutral {
1060
+ background: #d1ecf1;
1061
+ border-color: #17a2b8;
1062
+ }
1063
+
1064
+ .score-difference.neutral .diff-content strong,
1065
+ .score-difference.neutral .diff-content p {
1066
+ color: #0c5460;
1067
+ }
1068
+
1069
+ .score-difference.warning {
1070
+ background: #fff3cd;
1071
+ border-color: #ffc107;
1072
+ }
1073
+
1074
+ .score-difference.warning .diff-content strong,
1075
+ .score-difference.warning .diff-content p {
1076
+ color: #856404;
1077
+ }
1078
+
1079
+ .score-difference.positive {
1080
+ background: #d4edda;
1081
+ border-color: #28a745;
1082
+ }
1083
+
1084
+ .score-difference.positive .diff-content strong,
1085
+ .score-difference.positive .diff-content p {
1086
+ color: #155724;
1087
+ }
1088
+
1089
+ .methodology-note {
1090
+ background: #e7f3ff;
1091
+ border-left: 4px solid #3498db;
1092
+ padding: 15px;
1093
+ margin-top: 20px;
1094
+ border-radius: 4px;
1095
+ }
1096
+
1097
+ .methodology-note h5 {
1098
+ margin: 0 0 10px 0;
1099
+ color: #2c3e50;
1100
+ font-size: 1em;
1101
+ }
1102
+
1103
+ .methodology-note p {
1104
+ margin: 5px 0;
1105
+ color: #555;
1106
+ font-size: 0.9em;
1107
+ line-height: 1.6;
1108
+ }
1109
+
1110
+ .methodology-note a {
1111
+ color: #3498db;
1112
+ text-decoration: none;
1113
+ font-weight: 500;
1114
+ }
1115
+
1116
+ .methodology-note a:hover {
1117
+ text-decoration: underline;
1118
+ }
1119
+
1120
+ @media (max-width: 768px) {
1121
+ .comparison-grid {
1122
+ grid-template-columns: 1fr;
1123
+ }
1124
+
1125
+ .comparison-value {
1126
+ font-size: 2em;
1127
+ }
1128
+ }
1129
+
865
1130
  /* Print styles */
866
1131
  @media print {
867
1132
  .navigation {
@@ -1200,6 +1465,18 @@ class HTMLReporter(ReportGenerator):
1200
1465
  a.click();
1201
1466
  window.URL.revokeObjectURL(url);
1202
1467
  }}
1468
+
1469
+ // Toggle scoring details
1470
+ function toggleScoringDetails() {{
1471
+ const detailsSection = document.getElementById('scoringDetails');
1472
+ if (detailsSection) {{
1473
+ if (detailsSection.style.display === 'none') {{
1474
+ detailsSection.style.display = 'block';
1475
+ }} else {{
1476
+ detailsSection.style.display = 'none';
1477
+ }}
1478
+ }}
1479
+ }}
1203
1480
  """
1204
1481
 
1205
1482
  def _generate_header(self, html_data: Dict[str, Any]) -> str:
@@ -1262,14 +1539,22 @@ class HTMLReporter(ReportGenerator):
1262
1539
 
1263
1540
  # Generate metric cards
1264
1541
  overall_status = self._get_status_class(exec_summary.get("overall_compliance_percentage", 0))
1542
+ aws_config_score = exec_summary.get('aws_config_style_score', 0)
1543
+ score_diff = exec_summary.get('score_difference', 0)
1265
1544
 
1266
1545
  metric_cards = f"""
1267
1546
  <div class="metric-card {overall_status}">
1268
1547
  <div class="metric-value">{exec_summary.get('overall_compliance_percentage', 0):.1f}%</div>
1269
- <div class="metric-label">Overall Compliance</div>
1548
+ <div class="metric-label">Weighted Compliance Score</div>
1270
1549
  <div class="metric-trend trend-stable">Grade: {exec_summary.get('compliance_grade', 'N/A')}</div>
1271
1550
  </div>
1272
1551
 
1552
+ <div class="metric-card">
1553
+ <div class="metric-value">{aws_config_score:.1f}%</div>
1554
+ <div class="metric-label">AWS Config Style Score</div>
1555
+ <div class="metric-trend trend-stable">Unweighted</div>
1556
+ </div>
1557
+
1273
1558
  <div class="metric-card">
1274
1559
  <div class="metric-value">{exec_summary.get('total_resources', 0):,}</div>
1275
1560
  <div class="metric-label">Resources Evaluated</div>
@@ -1281,14 +1566,15 @@ class HTMLReporter(ReportGenerator):
1281
1566
  <div class="metric-label">Compliant Resources</div>
1282
1567
  <div class="metric-trend trend-up">{(exec_summary.get('compliant_resources', 0) / max(exec_summary.get('total_resources', 1), 1) * 100):.1f}% of total</div>
1283
1568
  </div>
1284
-
1285
- <div class="metric-card">
1286
- <div class="metric-value">{exec_summary.get('non_compliant_resources', 0):,}</div>
1287
- <div class="metric-label">Non-Compliant Resources</div>
1288
- <div class="metric-trend trend-down">Require attention</div>
1289
- </div>
1290
1569
  """
1291
1570
 
1571
+ # Add scoring comparison section
1572
+ score_comparison = self._generate_score_comparison_section(
1573
+ exec_summary.get('overall_compliance_percentage', 0),
1574
+ aws_config_score,
1575
+ score_diff
1576
+ )
1577
+
1292
1578
  # Generate IG progress bars
1293
1579
  ig_progress = ""
1294
1580
  for ig in ['ig1', 'ig2', 'ig3']:
@@ -1335,6 +1621,8 @@ class HTMLReporter(ReportGenerator):
1335
1621
  {metric_cards}
1336
1622
  </div>
1337
1623
 
1624
+ {score_comparison}
1625
+
1338
1626
  <div class="ig-progress-section">
1339
1627
  <h3>Implementation Groups Progress</h3>
1340
1628
  {ig_progress}
@@ -1368,6 +1656,21 @@ class HTMLReporter(ReportGenerator):
1368
1656
  findings_count = len(control_data.get("non_compliant_findings", []))
1369
1657
  status_class = self._get_status_class(control_data["compliance_percentage"])
1370
1658
 
1659
+ # Get display name (enriched in _enhance_html_structure)
1660
+ display_name = control_data.get('display_name', control_id)
1661
+ needs_truncation = control_data.get('needs_truncation', False)
1662
+
1663
+ # Add title attribute for truncated names
1664
+ title_attr = f' title="{display_name}"' if needs_truncation else ''
1665
+ display_name_class = 'control-display-name truncated' if needs_truncation else 'control-display-name'
1666
+
1667
+ # Get IG membership badges
1668
+ originating_ig = control_data.get('originating_ig', 'IG1')
1669
+ ig_badge_class = control_data.get('ig_badge_class', 'ig-badge-1')
1670
+
1671
+ # Build IG membership badges HTML
1672
+ ig_badges_html = f'<span class="ig-membership-badge {ig_badge_class}">{originating_ig}</span>'
1673
+
1371
1674
  # Add inheritance indicator for inherited controls
1372
1675
  inheritance_indicator = ""
1373
1676
  if ig_name != "IG1" and control_id in unique_controls.get("IG1", {}):
@@ -1378,10 +1681,12 @@ class HTMLReporter(ReportGenerator):
1378
1681
  controls_html += f"""
1379
1682
  <div class="control-card">
1380
1683
  <div class="control-header">
1381
- <div class="control-id">{control_id}</div>
1684
+ <div class="{display_name_class}"{title_attr}>{display_name}</div>
1382
1685
  <div class="badge {control_data.get('severity_badge', 'medium')}">{findings_count} Issues</div>
1383
1686
  </div>
1384
- <div class="control-title">{control_data.get('title', f'CIS Control {control_id}')}</div>
1687
+ <div class="ig-membership-badges">
1688
+ {ig_badges_html}
1689
+ </div>
1385
1690
  {inheritance_indicator}
1386
1691
  <div class="progress-container">
1387
1692
  <div class="progress-bar {status_class}" data-width="{control_data['compliance_percentage']}">
@@ -1430,39 +1735,73 @@ class HTMLReporter(ReportGenerator):
1430
1735
  def _generate_detailed_findings_section(self, html_data: Dict[str, Any]) -> str:
1431
1736
  """Generate detailed findings section.
1432
1737
 
1738
+ This method consolidates findings by control ID only (not by IG) to eliminate
1739
+ duplication. Each control appears once with IG membership indicators.
1740
+
1433
1741
  Args:
1434
1742
  html_data: Enhanced HTML report data
1435
1743
 
1436
1744
  Returns:
1437
1745
  Detailed findings HTML as string
1438
1746
  """
1439
- findings_sections = ""
1747
+ # Consolidate findings by control ID (deduplicated across IGs)
1748
+ consolidated_findings = self._consolidate_findings_by_control(
1749
+ html_data.get("implementation_groups", {})
1750
+ )
1440
1751
 
1441
- for ig_name, ig_findings in html_data["detailed_findings"].items():
1442
- ig_content = ""
1752
+ findings_content = ""
1753
+
1754
+ # Generate findings grouped by control ID only (sorted alphanumerically)
1755
+ for control_id, control_data in consolidated_findings.items():
1756
+ findings = control_data.get('findings', [])
1757
+
1758
+ # Skip if no non-compliant findings
1759
+ if not findings:
1760
+ continue
1761
+
1762
+ # Get control metadata
1763
+ config_rule_name = control_data.get('config_rule_name', '')
1764
+ title = control_data.get('title', f'CIS Control {control_id}')
1765
+ member_igs = control_data.get('member_igs', [])
1766
+
1767
+ # Format display name for collapsible header
1768
+ display_name = self._format_control_display_name(control_id, config_rule_name, title)
1769
+
1770
+ # Generate IG membership badges
1771
+ ig_badges_html = ""
1772
+ for ig in member_igs:
1773
+ badge_class = self._get_ig_badge_class(ig)
1774
+ ig_badges_html += f'<span class="ig-membership-badge {badge_class}">{ig}</span>'
1775
+
1776
+ # Generate findings table rows
1777
+ findings_rows = ""
1778
+ for finding in findings:
1779
+ if finding.get("compliance_status") == "NON_COMPLIANT":
1780
+ findings_rows += f"""
1781
+ <tr>
1782
+ <td>{finding.get('resource_id', 'N/A')}</td>
1783
+ <td>{finding.get('resource_type', 'N/A')}</td>
1784
+ <td>{finding.get('region', 'N/A')}</td>
1785
+ <td><span class="badge {finding.get('compliance_status', '').lower()}">{finding.get('compliance_status', 'UNKNOWN')}</span></td>
1786
+ <td>{finding.get('evaluation_reason', 'N/A')}</td>
1787
+ <td>{finding.get('config_rule_name', config_rule_name)}</td>
1788
+ </tr>
1789
+ """
1443
1790
 
1444
- for control_id, control_findings in ig_findings.items():
1445
- if not control_findings:
1446
- continue
1791
+ # Only add control section if there are non-compliant findings
1792
+ if findings_rows:
1793
+ non_compliant_count = len([f for f in findings if f.get('compliance_status') == 'NON_COMPLIANT'])
1447
1794
 
1448
- findings_rows = ""
1449
- for finding in control_findings:
1450
- if finding["compliance_status"] == "NON_COMPLIANT":
1451
- findings_rows += f"""
1452
- <tr>
1453
- <td>{finding['resource_id']}</td>
1454
- <td>{finding['resource_type']}</td>
1455
- <td>{finding['region']}</td>
1456
- <td><span class="badge {finding['compliance_status'].lower()}">{finding['compliance_status']}</span></td>
1457
- <td>{finding['evaluation_reason']}</td>
1458
- <td>{finding['config_rule_name']}</td>
1459
- </tr>
1460
- """
1461
-
1462
- if findings_rows:
1463
- ig_content += f"""
1464
- <button class="collapsible">{control_id} - Non-Compliant Resources ({len([f for f in control_findings if f['compliance_status'] == 'NON_COMPLIANT'])} items)</button>
1795
+ findings_content += f"""
1796
+ <div class="control-findings-section">
1797
+ <button class="collapsible">
1798
+ <span class="control-display-name">{display_name}</span>
1799
+ <span class="findings-count"> - Non-Compliant Resources ({non_compliant_count} items)</span>
1800
+ </button>
1465
1801
  <div class="collapsible-content">
1802
+ <div class="ig-membership-badges" style="margin-bottom: 15px;">
1803
+ <strong>Implementation Groups:</strong> {ig_badges_html}
1804
+ </div>
1466
1805
  <table class="findings-table">
1467
1806
  <thead>
1468
1807
  <tr>
@@ -1479,23 +1818,20 @@ class HTMLReporter(ReportGenerator):
1479
1818
  </tbody>
1480
1819
  </table>
1481
1820
  </div>
1482
- """
1483
-
1484
- if ig_content:
1485
- findings_sections += f"""
1486
- <div class="ig-findings">
1487
- <h3>{ig_name} Detailed Findings</h3>
1488
- {ig_content}
1489
1821
  </div>
1490
1822
  """
1491
1823
 
1492
1824
  return f"""
1493
1825
  <section id="detailed-findings" class="detailed-findings">
1494
1826
  <h2>Detailed Findings</h2>
1827
+ <p class="section-description">
1828
+ Findings are grouped by control ID and deduplicated across Implementation Groups.
1829
+ Each control shows which IGs include it.
1830
+ </p>
1495
1831
  <div class="search-container">
1496
1832
  <input type="text" placeholder="Search findings..." onkeyup="searchFindings(this.value)" style="width: 100%; padding: 10px; margin-bottom: 20px; border: 1px solid #ddd; border-radius: 5px;">
1497
1833
  </div>
1498
- {findings_sections}
1834
+ {findings_content if findings_content else '<p>No non-compliant findings to display.</p>'}
1499
1835
  </section>
1500
1836
  """
1501
1837
 
@@ -1762,6 +2098,90 @@ class HTMLReporter(ReportGenerator):
1762
2098
  display_findings.append(display_finding)
1763
2099
  return display_findings
1764
2100
 
2101
+ def _generate_score_comparison_section(self, weighted_score: float,
2102
+ aws_config_score: float,
2103
+ score_diff: float) -> str:
2104
+ """Generate scoring methodology comparison section.
2105
+
2106
+ Args:
2107
+ weighted_score: Our weighted compliance score
2108
+ aws_config_score: AWS Config Conformance Pack style score
2109
+ score_diff: Difference between the two scores
2110
+
2111
+ Returns:
2112
+ HTML section comparing the two scoring approaches
2113
+ """
2114
+ # Determine interpretation based on difference
2115
+ if abs(score_diff) < 1.0:
2116
+ interpretation = "Both scoring approaches show similar results, indicating balanced compliance across all control priorities."
2117
+ icon = "ℹ️"
2118
+ diff_class = "neutral"
2119
+ elif score_diff < 0:
2120
+ # Weighted score is lower
2121
+ interpretation = f"The weighted score is {abs(score_diff):.1f}% lower, indicating critical security controls need attention despite good overall resource compliance."
2122
+ icon = "⚠️"
2123
+ diff_class = "warning"
2124
+ else:
2125
+ # Weighted score is higher
2126
+ interpretation = f"The weighted score is {score_diff:.1f}% higher, indicating strong performance in critical security controls despite some gaps in less critical areas."
2127
+ icon = "✓"
2128
+ diff_class = "positive"
2129
+
2130
+ return f"""
2131
+ <div class="score-comparison-section">
2132
+ <h3>Scoring Methodology Comparison</h3>
2133
+ <div class="comparison-grid">
2134
+ <div class="comparison-card">
2135
+ <h4>Weighted Score (Our Approach)</h4>
2136
+ <div class="comparison-value">{weighted_score:.1f}%</div>
2137
+ <p class="comparison-description">
2138
+ Uses risk-based weighting where critical controls (encryption, access control)
2139
+ have higher impact on the overall score. Reflects actual security posture.
2140
+ </p>
2141
+ <ul class="comparison-features">
2142
+ <li>✓ Prioritizes critical security controls</li>
2143
+ <li>✓ Prevents resource count skew</li>
2144
+ <li>✓ Guides remediation priorities</li>
2145
+ </ul>
2146
+ </div>
2147
+
2148
+ <div class="comparison-card">
2149
+ <h4>AWS Config Style Score</h4>
2150
+ <div class="comparison-value">{aws_config_score:.1f}%</div>
2151
+ <p class="comparison-description">
2152
+ Simple unweighted calculation: compliant resources divided by total resources.
2153
+ All rules treated equally regardless of security criticality.
2154
+ </p>
2155
+ <ul class="comparison-features">
2156
+ <li>✓ Simple and straightforward</li>
2157
+ <li>✓ Easy to audit</li>
2158
+ <li>✓ Resource-level tracking</li>
2159
+ </ul>
2160
+ </div>
2161
+ </div>
2162
+
2163
+ <div class="score-difference {diff_class}">
2164
+ <span class="diff-icon">{icon}</span>
2165
+ <div class="diff-content">
2166
+ <strong>Score Difference: {score_diff:+.1f}%</strong>
2167
+ <p>{interpretation}</p>
2168
+ </div>
2169
+ </div>
2170
+
2171
+ <div class="methodology-note">
2172
+ <strong>Which score should I use?</strong>
2173
+ <ul>
2174
+ <li><strong>Weighted Score:</strong> Use for security decision-making, risk prioritization, and remediation planning</li>
2175
+ <li><strong>AWS Config Style:</strong> Use for compliance reporting, audits, and simple stakeholder communication</li>
2176
+ <li><strong>Both:</strong> Track both metrics for comprehensive security program management</li>
2177
+ </ul>
2178
+ <p>
2179
+ <a href="#" onclick="toggleScoringDetails(); return false;">Learn more about scoring methodologies →</a>
2180
+ </p>
2181
+ </div>
2182
+ </div>
2183
+ """
2184
+
1765
2185
  def set_chart_options(self, include_charts: bool = True) -> None:
1766
2186
  """Configure chart inclusion options.
1767
2187
 
@@ -1902,11 +2322,45 @@ class HTMLReporter(ReportGenerator):
1902
2322
  def _get_unique_controls_per_ig(self, implementation_groups: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
1903
2323
  """Get unique controls per Implementation Group to avoid duplication.
1904
2324
 
2325
+ Filters controls to show only those unique to each IG level, eliminating
2326
+ redundancy in the Implementation Groups section. IG2 shows only controls
2327
+ not in IG1, and IG3 shows only controls not in IG1 or IG2.
2328
+
1905
2329
  Args:
1906
- implementation_groups: Implementation groups data
2330
+ implementation_groups: Implementation groups data with all controls
1907
2331
 
1908
2332
  Returns:
1909
- Dictionary mapping IG names to their unique controls
2333
+ Dictionary mapping IG names to their unique controls:
2334
+ - IG1: All IG1 controls (foundational)
2335
+ - IG2: Only controls unique to IG2 (not in IG1)
2336
+ - IG3: Only controls unique to IG3 (not in IG1 or IG2)
2337
+
2338
+ Examples:
2339
+ Input:
2340
+ {
2341
+ 'IG1': {'controls': {'1.1': {...}, '1.5': {...}}},
2342
+ 'IG2': {'controls': {'1.1': {...}, '1.5': {...}, '5.2': {...}}},
2343
+ 'IG3': {'controls': {'1.1': {...}, '1.5': {...}, '5.2': {...}, '13.1': {...}}}
2344
+ }
2345
+
2346
+ Output:
2347
+ {
2348
+ 'IG1': {'1.1': {...}, '1.5': {...}}, # All IG1 controls
2349
+ 'IG2': {'5.2': {...}}, # Only 5.2 is unique to IG2
2350
+ 'IG3': {'13.1': {...}} # Only 13.1 is unique to IG3
2351
+ }
2352
+
2353
+ Rationale:
2354
+ - Eliminates duplicate control cards across IG sections
2355
+ - Users see each control once in its originating IG
2356
+ - Reduces visual clutter and improves report readability
2357
+ - IG membership badges show which IGs include each control
2358
+
2359
+ Notes:
2360
+ - IG1 controls are always shown in full (foundational set)
2361
+ - Higher IGs show only their incremental additions
2362
+ - Cumulative nature is explained in the IG explanation box
2363
+ - Used by _generate_implementation_groups_section()
1910
2364
  """
1911
2365
  unique_controls = {}
1912
2366
 
@@ -2159,4 +2613,331 @@ class HTMLReporter(ReportGenerator):
2159
2613
  options = ""
2160
2614
  for resource_type in sorted(resource_type_stats.keys()):
2161
2615
  options += f'<option value="{resource_type}">{resource_type}</option>'
2162
- return options
2616
+ return options
2617
+
2618
+ def _format_control_display_name(self, control_id: str, config_rule_name: str, title: Optional[str] = None) -> str:
2619
+ """Format control display name combining ID, rule name, and optional title.
2620
+
2621
+ Creates a human-readable display name that shows both the control identifier
2622
+ and the AWS Config rule name, making it easier for users to understand what
2623
+ each control checks without looking up documentation.
2624
+
2625
+ Args:
2626
+ control_id: Control identifier (e.g., "1.5", "2.1")
2627
+ config_rule_name: AWS Config rule name (e.g., "root-account-hardware-mfa-enabled")
2628
+ title: Optional human-readable title for the control
2629
+
2630
+ Returns:
2631
+ Formatted string for display in the following formats:
2632
+ - With title: "{control_id}: {title} ({config_rule_name})"
2633
+ - Without title: "{control_id}: {config_rule_name}"
2634
+ - Fallback (no rule name): "{control_id}"
2635
+
2636
+ Examples:
2637
+ >>> _format_control_display_name("1.5", "root-account-hardware-mfa-enabled")
2638
+ "1.5: root-account-hardware-mfa-enabled"
2639
+
2640
+ >>> _format_control_display_name("2.1", "iam-password-policy", "IAM Password Policy")
2641
+ "2.1: IAM Password Policy (iam-password-policy)"
2642
+
2643
+ >>> _format_control_display_name("3.1", "")
2644
+ "3.1"
2645
+
2646
+ Notes:
2647
+ - Gracefully handles missing config_rule_name by falling back to control_id only
2648
+ - Used in both Implementation Groups and Detailed Findings sections
2649
+ - Display names longer than 50 characters are truncated with tooltips
2650
+ """
2651
+ if not config_rule_name:
2652
+ # Fallback to control_id only if config_rule_name is missing
2653
+ return control_id
2654
+
2655
+ if title:
2656
+ return f"{control_id}: {title} ({config_rule_name})"
2657
+ else:
2658
+ return f"{control_id}: {config_rule_name}"
2659
+
2660
+ def _get_ig_badge_class(self, ig_name: str) -> str:
2661
+ """Get CSS class for IG badge styling.
2662
+
2663
+ Returns the appropriate CSS class for styling Implementation Group badges
2664
+ with consistent color coding across the report.
2665
+
2666
+ Args:
2667
+ ig_name: Implementation Group name (IG1, IG2, or IG3)
2668
+
2669
+ Returns:
2670
+ CSS class name for the badge:
2671
+ - 'ig-badge-1' for IG1 (blue styling)
2672
+ - 'ig-badge-2' for IG2 (green styling)
2673
+ - 'ig-badge-3' for IG3 (purple styling)
2674
+ - 'ig-badge-default' for unknown IGs (gray styling)
2675
+
2676
+ Examples:
2677
+ >>> _get_ig_badge_class("IG1")
2678
+ "ig-badge-1"
2679
+
2680
+ >>> _get_ig_badge_class("IG2")
2681
+ "ig-badge-2"
2682
+
2683
+ >>> _get_ig_badge_class("UNKNOWN")
2684
+ "ig-badge-default"
2685
+
2686
+ CSS Styling:
2687
+ .ig-badge-1 { background-color: #3498db; color: white; } /* Blue */
2688
+ .ig-badge-2 { background-color: #27ae60; color: white; } /* Green */
2689
+ .ig-badge-3 { background-color: #9b59b6; color: white; } /* Purple */
2690
+
2691
+ Notes:
2692
+ - Used consistently across Implementation Groups and Detailed Findings sections
2693
+ - Provides visual hierarchy for IG levels
2694
+ - Can be customized via CSS for different color schemes
2695
+ """
2696
+ badge_classes = {
2697
+ 'IG1': 'ig-badge-1', # Blue
2698
+ 'IG2': 'ig-badge-2', # Green
2699
+ 'IG3': 'ig-badge-3' # Purple
2700
+ }
2701
+ return badge_classes.get(ig_name, 'ig-badge-default')
2702
+
2703
+ def _enrich_control_metadata(self, control_data: Dict[str, Any], control_id: str, ig_name: str,
2704
+ all_igs: Dict[str, Any]) -> Dict[str, Any]:
2705
+ """Add display metadata to control data for enhanced HTML presentation.
2706
+
2707
+ Enriches control data with additional fields needed for improved display,
2708
+ including formatted names, IG membership badges, and truncation indicators.
2709
+
2710
+ Args:
2711
+ control_data: Existing control data dictionary
2712
+ control_id: Control identifier (e.g., "1.5")
2713
+ ig_name: Implementation Group name (e.g., "IG1")
2714
+ all_igs: All implementation groups data for determining originating IG
2715
+
2716
+ Returns:
2717
+ Enhanced control data with additional fields:
2718
+ - display_name: Formatted name combining control ID and rule name
2719
+ - originating_ig: Which IG introduced this control (IG1, IG2, or IG3)
2720
+ - ig_badge_class: CSS class for IG badge styling
2721
+ - needs_truncation: Boolean indicating if display name exceeds 50 characters
2722
+
2723
+ Examples:
2724
+ Input control_data:
2725
+ {
2726
+ 'control_id': '1.5',
2727
+ 'config_rule_name': 'root-account-hardware-mfa-enabled',
2728
+ 'compliance_percentage': 0.0,
2729
+ 'total_resources': 1
2730
+ }
2731
+
2732
+ Output enriched data (includes all input fields plus):
2733
+ {
2734
+ ...original fields...,
2735
+ 'display_name': '1.5: root-account-hardware-mfa-enabled',
2736
+ 'originating_ig': 'IG1',
2737
+ 'ig_badge_class': 'ig-badge-1',
2738
+ 'needs_truncation': False
2739
+ }
2740
+
2741
+ Notes:
2742
+ - Called during _enhance_html_structure() for each control
2743
+ - Truncation threshold is 50 characters
2744
+ - Gracefully handles missing config_rule_name
2745
+ - Originating IG is determined by checking IG1, IG2, IG3 in order
2746
+ """
2747
+ enriched = control_data.copy()
2748
+
2749
+ # Format display name
2750
+ enriched['display_name'] = self._format_control_display_name(
2751
+ control_id,
2752
+ control_data.get('config_rule_name', ''),
2753
+ control_data.get('title')
2754
+ )
2755
+
2756
+ # Determine originating IG (which IG introduced this control)
2757
+ originating_ig = self._determine_originating_ig(control_id, all_igs)
2758
+ enriched['originating_ig'] = originating_ig
2759
+
2760
+ # Get badge class for the originating IG
2761
+ enriched['ig_badge_class'] = self._get_ig_badge_class(originating_ig)
2762
+
2763
+ # Check if truncation is needed (threshold: 50 characters)
2764
+ enriched['needs_truncation'] = len(enriched['display_name']) > 50
2765
+
2766
+ return enriched
2767
+
2768
+ def _determine_originating_ig(self, control_id: str, all_igs: Dict[str, Any]) -> str:
2769
+ """Determine which Implementation Group introduced a control.
2770
+
2771
+ Args:
2772
+ control_id: Control identifier
2773
+ all_igs: All implementation groups data
2774
+
2775
+ Returns:
2776
+ Name of the IG that introduced this control (IG1, IG2, or IG3)
2777
+ """
2778
+ # Check in order: IG1, IG2, IG3
2779
+ # The first IG that contains the control is the originating IG
2780
+ for ig_name in ['IG1', 'IG2', 'IG3']:
2781
+ if ig_name in all_igs:
2782
+ if control_id in all_igs[ig_name].get('controls', {}):
2783
+ return ig_name
2784
+
2785
+ # Default to IG1 if not found
2786
+ return 'IG1'
2787
+
2788
+ def _consolidate_findings_by_control(self, implementation_groups: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
2789
+ """Consolidate findings from all IGs, grouped by control ID only.
2790
+
2791
+ This method deduplicates findings across Implementation Groups so that each
2792
+ finding appears only once in the Detailed Findings section, eliminating
2793
+ redundancy when a control appears in multiple IGs.
2794
+
2795
+ Args:
2796
+ implementation_groups: Implementation groups data with controls and findings
2797
+
2798
+ Returns:
2799
+ Dictionary mapping control_id -> consolidated control data with:
2800
+ - findings: List of deduplicated non-compliant findings
2801
+ - member_igs: List of IGs that include this control (e.g., ["IG1", "IG2"])
2802
+ - config_rule_name: AWS Config rule name for the control
2803
+ - title: Human-readable title for the control
2804
+
2805
+ Results are sorted by control ID in alphanumeric order.
2806
+
2807
+ Examples:
2808
+ Input: Control "1.5" appears in IG1, IG2, and IG3 with same findings
2809
+ Output: Single entry for "1.5" with:
2810
+ {
2811
+ '1.5': {
2812
+ 'findings': [...deduplicated findings...],
2813
+ 'member_igs': ['IG1', 'IG2', 'IG3'],
2814
+ 'config_rule_name': 'root-account-hardware-mfa-enabled',
2815
+ 'title': 'Root Account Hardware MFA'
2816
+ }
2817
+ }
2818
+
2819
+ Deduplication Strategy:
2820
+ - Uses (resource_id, control_id, region) tuple as unique key
2821
+ - Prevents same resource from appearing multiple times
2822
+ - Preserves all unique findings across IGs
2823
+
2824
+ Sorting:
2825
+ - Controls are sorted alphanumerically (1.1, 1.2, ..., 1.10, 2.1, ...)
2826
+ - Uses _sort_control_id() helper for proper numeric sorting
2827
+
2828
+ Notes:
2829
+ - Eliminates "IG1 Detailed Findings", "IG2 Detailed Findings" subsections
2830
+ - Each control appears once with IG membership indicators
2831
+ - Improves report readability and reduces redundancy
2832
+ """
2833
+ consolidated = {}
2834
+ seen_findings = set() # Track (resource_id, control_id, region) tuples for deduplication
2835
+
2836
+ for ig_name, ig_data in implementation_groups.items():
2837
+ for control_id, control_data in ig_data.get('controls', {}).items():
2838
+ # Initialize control entry if not exists
2839
+ if control_id not in consolidated:
2840
+ consolidated[control_id] = {
2841
+ 'findings': [],
2842
+ 'member_igs': [],
2843
+ 'config_rule_name': control_data.get('config_rule_name', ''),
2844
+ 'title': control_data.get('title', f'CIS Control {control_id}')
2845
+ }
2846
+
2847
+ # Track which IGs include this control
2848
+ consolidated[control_id]['member_igs'].append(ig_name)
2849
+
2850
+ # Add non-compliant findings (deduplicated)
2851
+ for finding in control_data.get('non_compliant_findings', []):
2852
+ finding_key = (finding.get('resource_id', ''), control_id, finding.get('region', ''))
2853
+ if finding_key not in seen_findings:
2854
+ consolidated[control_id]['findings'].append(finding)
2855
+ seen_findings.add(finding_key)
2856
+
2857
+ # Sort by control ID using alphanumeric sorting
2858
+ return dict(sorted(consolidated.items(), key=lambda x: self._sort_control_id(x[0])))
2859
+
2860
+ def _get_control_ig_membership(self, control_id: str, implementation_groups: Dict[str, Any]) -> List[str]:
2861
+ """Determine which Implementation Groups include a specific control.
2862
+
2863
+ Checks all Implementation Groups (IG1, IG2, IG3) to identify which ones
2864
+ contain the specified control, enabling display of IG membership badges.
2865
+
2866
+ Args:
2867
+ control_id: Control identifier (e.g., "1.5", "2.1")
2868
+ implementation_groups: All IG data from the assessment
2869
+
2870
+ Returns:
2871
+ List of IG names that include this control, in order.
2872
+ Examples:
2873
+ - ["IG1", "IG2", "IG3"] for a control in all IGs
2874
+ - ["IG1", "IG2"] for a control in IG1 and IG2 only
2875
+ - ["IG3"] for a control unique to IG3
2876
+ - [] for a control not found in any IG
2877
+
2878
+ Examples:
2879
+ >>> _get_control_ig_membership("1.5", implementation_groups)
2880
+ ["IG1", "IG2", "IG3"] # Control 1.5 is in all IGs
2881
+
2882
+ >>> _get_control_ig_membership("5.2", implementation_groups)
2883
+ ["IG2", "IG3"] # Control 5.2 is only in IG2 and IG3
2884
+
2885
+ Notes:
2886
+ - Used to display IG membership badges in Detailed Findings section
2887
+ - Helps users understand which IGs require remediation for each control
2888
+ - Checks IGs in order: IG1, IG2, IG3
2889
+ """
2890
+ member_igs = []
2891
+ for ig_name in ['IG1', 'IG2', 'IG3']:
2892
+ if ig_name in implementation_groups:
2893
+ if control_id in implementation_groups[ig_name].get('controls', {}):
2894
+ member_igs.append(ig_name)
2895
+ return member_igs
2896
+
2897
+ def _sort_control_id(self, control_id: str) -> tuple:
2898
+ """Helper for alphanumeric sorting of control IDs.
2899
+
2900
+ Converts control IDs like "1.1", "1.10", "2.1" into tuples of integers
2901
+ for proper alphanumeric sorting. This ensures controls are displayed in
2902
+ the correct order (1.1, 1.2, ..., 1.9, 1.10, 2.1, ...) rather than
2903
+ lexicographic order (1.1, 1.10, 1.2, ...).
2904
+
2905
+ Args:
2906
+ control_id: Control identifier (e.g., "1.1", "1.10", "2.1")
2907
+
2908
+ Returns:
2909
+ Tuple of integers for sorting (e.g., (1, 1), (1, 10), (2, 1))
2910
+ Returns (0, 0) for non-standard control IDs as fallback
2911
+
2912
+ Examples:
2913
+ >>> _sort_control_id("1.1")
2914
+ (1, 1)
2915
+
2916
+ >>> _sort_control_id("1.10")
2917
+ (1, 10)
2918
+
2919
+ >>> _sort_control_id("2.1")
2920
+ (2, 1)
2921
+
2922
+ >>> _sort_control_id("invalid")
2923
+ (0, 0) # Fallback for non-standard IDs
2924
+
2925
+ Sorting Behavior:
2926
+ Without this helper:
2927
+ ["1.1", "1.10", "1.2", "2.1"] # Incorrect lexicographic order
2928
+
2929
+ With this helper:
2930
+ ["1.1", "1.2", "1.10", "2.1"] # Correct numeric order
2931
+
2932
+ Notes:
2933
+ - Used in _consolidate_findings_by_control() for sorting
2934
+ - Handles multi-level control IDs (e.g., "1.2.3" -> (1, 2, 3))
2935
+ - Gracefully handles malformed control IDs
2936
+ """
2937
+ try:
2938
+ # Split by '.' and convert to integers
2939
+ parts = control_id.split('.')
2940
+ return tuple(int(part) for part in parts)
2941
+ except (ValueError, AttributeError):
2942
+ # Fallback for non-standard control IDs
2943
+ return (0, 0)