aws-cis-controls-assessment 1.0.6__py3-none-any.whl → 1.0.8__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;
@@ -1368,6 +1468,21 @@ class HTMLReporter(ReportGenerator):
1368
1468
  findings_count = len(control_data.get("non_compliant_findings", []))
1369
1469
  status_class = self._get_status_class(control_data["compliance_percentage"])
1370
1470
 
1471
+ # Get display name (enriched in _enhance_html_structure)
1472
+ display_name = control_data.get('display_name', control_id)
1473
+ needs_truncation = control_data.get('needs_truncation', False)
1474
+
1475
+ # Add title attribute for truncated names
1476
+ title_attr = f' title="{display_name}"' if needs_truncation else ''
1477
+ display_name_class = 'control-display-name truncated' if needs_truncation else 'control-display-name'
1478
+
1479
+ # Get IG membership badges
1480
+ originating_ig = control_data.get('originating_ig', 'IG1')
1481
+ ig_badge_class = control_data.get('ig_badge_class', 'ig-badge-1')
1482
+
1483
+ # Build IG membership badges HTML
1484
+ ig_badges_html = f'<span class="ig-membership-badge {ig_badge_class}">{originating_ig}</span>'
1485
+
1371
1486
  # Add inheritance indicator for inherited controls
1372
1487
  inheritance_indicator = ""
1373
1488
  if ig_name != "IG1" and control_id in unique_controls.get("IG1", {}):
@@ -1378,10 +1493,12 @@ class HTMLReporter(ReportGenerator):
1378
1493
  controls_html += f"""
1379
1494
  <div class="control-card">
1380
1495
  <div class="control-header">
1381
- <div class="control-id">{control_id}</div>
1496
+ <div class="{display_name_class}"{title_attr}>{display_name}</div>
1382
1497
  <div class="badge {control_data.get('severity_badge', 'medium')}">{findings_count} Issues</div>
1383
1498
  </div>
1384
- <div class="control-title">{control_data.get('title', f'CIS Control {control_id}')}</div>
1499
+ <div class="ig-membership-badges">
1500
+ {ig_badges_html}
1501
+ </div>
1385
1502
  {inheritance_indicator}
1386
1503
  <div class="progress-container">
1387
1504
  <div class="progress-bar {status_class}" data-width="{control_data['compliance_percentage']}">
@@ -1430,39 +1547,73 @@ class HTMLReporter(ReportGenerator):
1430
1547
  def _generate_detailed_findings_section(self, html_data: Dict[str, Any]) -> str:
1431
1548
  """Generate detailed findings section.
1432
1549
 
1550
+ This method consolidates findings by control ID only (not by IG) to eliminate
1551
+ duplication. Each control appears once with IG membership indicators.
1552
+
1433
1553
  Args:
1434
1554
  html_data: Enhanced HTML report data
1435
1555
 
1436
1556
  Returns:
1437
1557
  Detailed findings HTML as string
1438
1558
  """
1439
- findings_sections = ""
1559
+ # Consolidate findings by control ID (deduplicated across IGs)
1560
+ consolidated_findings = self._consolidate_findings_by_control(
1561
+ html_data.get("implementation_groups", {})
1562
+ )
1440
1563
 
1441
- for ig_name, ig_findings in html_data["detailed_findings"].items():
1442
- ig_content = ""
1564
+ findings_content = ""
1565
+
1566
+ # Generate findings grouped by control ID only (sorted alphanumerically)
1567
+ for control_id, control_data in consolidated_findings.items():
1568
+ findings = control_data.get('findings', [])
1569
+
1570
+ # Skip if no non-compliant findings
1571
+ if not findings:
1572
+ continue
1573
+
1574
+ # Get control metadata
1575
+ config_rule_name = control_data.get('config_rule_name', '')
1576
+ title = control_data.get('title', f'CIS Control {control_id}')
1577
+ member_igs = control_data.get('member_igs', [])
1578
+
1579
+ # Format display name for collapsible header
1580
+ display_name = self._format_control_display_name(control_id, config_rule_name, title)
1581
+
1582
+ # Generate IG membership badges
1583
+ ig_badges_html = ""
1584
+ for ig in member_igs:
1585
+ badge_class = self._get_ig_badge_class(ig)
1586
+ ig_badges_html += f'<span class="ig-membership-badge {badge_class}">{ig}</span>'
1587
+
1588
+ # Generate findings table rows
1589
+ findings_rows = ""
1590
+ for finding in findings:
1591
+ if finding.get("compliance_status") == "NON_COMPLIANT":
1592
+ findings_rows += f"""
1593
+ <tr>
1594
+ <td>{finding.get('resource_id', 'N/A')}</td>
1595
+ <td>{finding.get('resource_type', 'N/A')}</td>
1596
+ <td>{finding.get('region', 'N/A')}</td>
1597
+ <td><span class="badge {finding.get('compliance_status', '').lower()}">{finding.get('compliance_status', 'UNKNOWN')}</span></td>
1598
+ <td>{finding.get('evaluation_reason', 'N/A')}</td>
1599
+ <td>{finding.get('config_rule_name', config_rule_name)}</td>
1600
+ </tr>
1601
+ """
1443
1602
 
1444
- for control_id, control_findings in ig_findings.items():
1445
- if not control_findings:
1446
- continue
1447
-
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
- """
1603
+ # Only add control section if there are non-compliant findings
1604
+ if findings_rows:
1605
+ non_compliant_count = len([f for f in findings if f.get('compliance_status') == 'NON_COMPLIANT'])
1461
1606
 
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>
1607
+ findings_content += f"""
1608
+ <div class="control-findings-section">
1609
+ <button class="collapsible">
1610
+ <span class="control-display-name">{display_name}</span>
1611
+ <span class="findings-count"> - Non-Compliant Resources ({non_compliant_count} items)</span>
1612
+ </button>
1465
1613
  <div class="collapsible-content">
1614
+ <div class="ig-membership-badges" style="margin-bottom: 15px;">
1615
+ <strong>Implementation Groups:</strong> {ig_badges_html}
1616
+ </div>
1466
1617
  <table class="findings-table">
1467
1618
  <thead>
1468
1619
  <tr>
@@ -1479,23 +1630,20 @@ class HTMLReporter(ReportGenerator):
1479
1630
  </tbody>
1480
1631
  </table>
1481
1632
  </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
1633
  </div>
1490
1634
  """
1491
1635
 
1492
1636
  return f"""
1493
1637
  <section id="detailed-findings" class="detailed-findings">
1494
1638
  <h2>Detailed Findings</h2>
1639
+ <p class="section-description">
1640
+ Findings are grouped by control ID and deduplicated across Implementation Groups.
1641
+ Each control shows which IGs include it.
1642
+ </p>
1495
1643
  <div class="search-container">
1496
1644
  <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
1645
  </div>
1498
- {findings_sections}
1646
+ {findings_content if findings_content else '<p>No non-compliant findings to display.</p>'}
1499
1647
  </section>
1500
1648
  """
1501
1649
 
@@ -1902,11 +2050,45 @@ class HTMLReporter(ReportGenerator):
1902
2050
  def _get_unique_controls_per_ig(self, implementation_groups: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
1903
2051
  """Get unique controls per Implementation Group to avoid duplication.
1904
2052
 
2053
+ Filters controls to show only those unique to each IG level, eliminating
2054
+ redundancy in the Implementation Groups section. IG2 shows only controls
2055
+ not in IG1, and IG3 shows only controls not in IG1 or IG2.
2056
+
1905
2057
  Args:
1906
- implementation_groups: Implementation groups data
2058
+ implementation_groups: Implementation groups data with all controls
1907
2059
 
1908
2060
  Returns:
1909
- Dictionary mapping IG names to their unique controls
2061
+ Dictionary mapping IG names to their unique controls:
2062
+ - IG1: All IG1 controls (foundational)
2063
+ - IG2: Only controls unique to IG2 (not in IG1)
2064
+ - IG3: Only controls unique to IG3 (not in IG1 or IG2)
2065
+
2066
+ Examples:
2067
+ Input:
2068
+ {
2069
+ 'IG1': {'controls': {'1.1': {...}, '1.5': {...}}},
2070
+ 'IG2': {'controls': {'1.1': {...}, '1.5': {...}, '5.2': {...}}},
2071
+ 'IG3': {'controls': {'1.1': {...}, '1.5': {...}, '5.2': {...}, '13.1': {...}}}
2072
+ }
2073
+
2074
+ Output:
2075
+ {
2076
+ 'IG1': {'1.1': {...}, '1.5': {...}}, # All IG1 controls
2077
+ 'IG2': {'5.2': {...}}, # Only 5.2 is unique to IG2
2078
+ 'IG3': {'13.1': {...}} # Only 13.1 is unique to IG3
2079
+ }
2080
+
2081
+ Rationale:
2082
+ - Eliminates duplicate control cards across IG sections
2083
+ - Users see each control once in its originating IG
2084
+ - Reduces visual clutter and improves report readability
2085
+ - IG membership badges show which IGs include each control
2086
+
2087
+ Notes:
2088
+ - IG1 controls are always shown in full (foundational set)
2089
+ - Higher IGs show only their incremental additions
2090
+ - Cumulative nature is explained in the IG explanation box
2091
+ - Used by _generate_implementation_groups_section()
1910
2092
  """
1911
2093
  unique_controls = {}
1912
2094
 
@@ -2159,4 +2341,331 @@ class HTMLReporter(ReportGenerator):
2159
2341
  options = ""
2160
2342
  for resource_type in sorted(resource_type_stats.keys()):
2161
2343
  options += f'<option value="{resource_type}">{resource_type}</option>'
2162
- return options
2344
+ return options
2345
+
2346
+ def _format_control_display_name(self, control_id: str, config_rule_name: str, title: Optional[str] = None) -> str:
2347
+ """Format control display name combining ID, rule name, and optional title.
2348
+
2349
+ Creates a human-readable display name that shows both the control identifier
2350
+ and the AWS Config rule name, making it easier for users to understand what
2351
+ each control checks without looking up documentation.
2352
+
2353
+ Args:
2354
+ control_id: Control identifier (e.g., "1.5", "2.1")
2355
+ config_rule_name: AWS Config rule name (e.g., "root-account-hardware-mfa-enabled")
2356
+ title: Optional human-readable title for the control
2357
+
2358
+ Returns:
2359
+ Formatted string for display in the following formats:
2360
+ - With title: "{control_id}: {title} ({config_rule_name})"
2361
+ - Without title: "{control_id}: {config_rule_name}"
2362
+ - Fallback (no rule name): "{control_id}"
2363
+
2364
+ Examples:
2365
+ >>> _format_control_display_name("1.5", "root-account-hardware-mfa-enabled")
2366
+ "1.5: root-account-hardware-mfa-enabled"
2367
+
2368
+ >>> _format_control_display_name("2.1", "iam-password-policy", "IAM Password Policy")
2369
+ "2.1: IAM Password Policy (iam-password-policy)"
2370
+
2371
+ >>> _format_control_display_name("3.1", "")
2372
+ "3.1"
2373
+
2374
+ Notes:
2375
+ - Gracefully handles missing config_rule_name by falling back to control_id only
2376
+ - Used in both Implementation Groups and Detailed Findings sections
2377
+ - Display names longer than 50 characters are truncated with tooltips
2378
+ """
2379
+ if not config_rule_name:
2380
+ # Fallback to control_id only if config_rule_name is missing
2381
+ return control_id
2382
+
2383
+ if title:
2384
+ return f"{control_id}: {title} ({config_rule_name})"
2385
+ else:
2386
+ return f"{control_id}: {config_rule_name}"
2387
+
2388
+ def _get_ig_badge_class(self, ig_name: str) -> str:
2389
+ """Get CSS class for IG badge styling.
2390
+
2391
+ Returns the appropriate CSS class for styling Implementation Group badges
2392
+ with consistent color coding across the report.
2393
+
2394
+ Args:
2395
+ ig_name: Implementation Group name (IG1, IG2, or IG3)
2396
+
2397
+ Returns:
2398
+ CSS class name for the badge:
2399
+ - 'ig-badge-1' for IG1 (blue styling)
2400
+ - 'ig-badge-2' for IG2 (green styling)
2401
+ - 'ig-badge-3' for IG3 (purple styling)
2402
+ - 'ig-badge-default' for unknown IGs (gray styling)
2403
+
2404
+ Examples:
2405
+ >>> _get_ig_badge_class("IG1")
2406
+ "ig-badge-1"
2407
+
2408
+ >>> _get_ig_badge_class("IG2")
2409
+ "ig-badge-2"
2410
+
2411
+ >>> _get_ig_badge_class("UNKNOWN")
2412
+ "ig-badge-default"
2413
+
2414
+ CSS Styling:
2415
+ .ig-badge-1 { background-color: #3498db; color: white; } /* Blue */
2416
+ .ig-badge-2 { background-color: #27ae60; color: white; } /* Green */
2417
+ .ig-badge-3 { background-color: #9b59b6; color: white; } /* Purple */
2418
+
2419
+ Notes:
2420
+ - Used consistently across Implementation Groups and Detailed Findings sections
2421
+ - Provides visual hierarchy for IG levels
2422
+ - Can be customized via CSS for different color schemes
2423
+ """
2424
+ badge_classes = {
2425
+ 'IG1': 'ig-badge-1', # Blue
2426
+ 'IG2': 'ig-badge-2', # Green
2427
+ 'IG3': 'ig-badge-3' # Purple
2428
+ }
2429
+ return badge_classes.get(ig_name, 'ig-badge-default')
2430
+
2431
+ def _enrich_control_metadata(self, control_data: Dict[str, Any], control_id: str, ig_name: str,
2432
+ all_igs: Dict[str, Any]) -> Dict[str, Any]:
2433
+ """Add display metadata to control data for enhanced HTML presentation.
2434
+
2435
+ Enriches control data with additional fields needed for improved display,
2436
+ including formatted names, IG membership badges, and truncation indicators.
2437
+
2438
+ Args:
2439
+ control_data: Existing control data dictionary
2440
+ control_id: Control identifier (e.g., "1.5")
2441
+ ig_name: Implementation Group name (e.g., "IG1")
2442
+ all_igs: All implementation groups data for determining originating IG
2443
+
2444
+ Returns:
2445
+ Enhanced control data with additional fields:
2446
+ - display_name: Formatted name combining control ID and rule name
2447
+ - originating_ig: Which IG introduced this control (IG1, IG2, or IG3)
2448
+ - ig_badge_class: CSS class for IG badge styling
2449
+ - needs_truncation: Boolean indicating if display name exceeds 50 characters
2450
+
2451
+ Examples:
2452
+ Input control_data:
2453
+ {
2454
+ 'control_id': '1.5',
2455
+ 'config_rule_name': 'root-account-hardware-mfa-enabled',
2456
+ 'compliance_percentage': 0.0,
2457
+ 'total_resources': 1
2458
+ }
2459
+
2460
+ Output enriched data (includes all input fields plus):
2461
+ {
2462
+ ...original fields...,
2463
+ 'display_name': '1.5: root-account-hardware-mfa-enabled',
2464
+ 'originating_ig': 'IG1',
2465
+ 'ig_badge_class': 'ig-badge-1',
2466
+ 'needs_truncation': False
2467
+ }
2468
+
2469
+ Notes:
2470
+ - Called during _enhance_html_structure() for each control
2471
+ - Truncation threshold is 50 characters
2472
+ - Gracefully handles missing config_rule_name
2473
+ - Originating IG is determined by checking IG1, IG2, IG3 in order
2474
+ """
2475
+ enriched = control_data.copy()
2476
+
2477
+ # Format display name
2478
+ enriched['display_name'] = self._format_control_display_name(
2479
+ control_id,
2480
+ control_data.get('config_rule_name', ''),
2481
+ control_data.get('title')
2482
+ )
2483
+
2484
+ # Determine originating IG (which IG introduced this control)
2485
+ originating_ig = self._determine_originating_ig(control_id, all_igs)
2486
+ enriched['originating_ig'] = originating_ig
2487
+
2488
+ # Get badge class for the originating IG
2489
+ enriched['ig_badge_class'] = self._get_ig_badge_class(originating_ig)
2490
+
2491
+ # Check if truncation is needed (threshold: 50 characters)
2492
+ enriched['needs_truncation'] = len(enriched['display_name']) > 50
2493
+
2494
+ return enriched
2495
+
2496
+ def _determine_originating_ig(self, control_id: str, all_igs: Dict[str, Any]) -> str:
2497
+ """Determine which Implementation Group introduced a control.
2498
+
2499
+ Args:
2500
+ control_id: Control identifier
2501
+ all_igs: All implementation groups data
2502
+
2503
+ Returns:
2504
+ Name of the IG that introduced this control (IG1, IG2, or IG3)
2505
+ """
2506
+ # Check in order: IG1, IG2, IG3
2507
+ # The first IG that contains the control is the originating IG
2508
+ for ig_name in ['IG1', 'IG2', 'IG3']:
2509
+ if ig_name in all_igs:
2510
+ if control_id in all_igs[ig_name].get('controls', {}):
2511
+ return ig_name
2512
+
2513
+ # Default to IG1 if not found
2514
+ return 'IG1'
2515
+
2516
+ def _consolidate_findings_by_control(self, implementation_groups: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
2517
+ """Consolidate findings from all IGs, grouped by control ID only.
2518
+
2519
+ This method deduplicates findings across Implementation Groups so that each
2520
+ finding appears only once in the Detailed Findings section, eliminating
2521
+ redundancy when a control appears in multiple IGs.
2522
+
2523
+ Args:
2524
+ implementation_groups: Implementation groups data with controls and findings
2525
+
2526
+ Returns:
2527
+ Dictionary mapping control_id -> consolidated control data with:
2528
+ - findings: List of deduplicated non-compliant findings
2529
+ - member_igs: List of IGs that include this control (e.g., ["IG1", "IG2"])
2530
+ - config_rule_name: AWS Config rule name for the control
2531
+ - title: Human-readable title for the control
2532
+
2533
+ Results are sorted by control ID in alphanumeric order.
2534
+
2535
+ Examples:
2536
+ Input: Control "1.5" appears in IG1, IG2, and IG3 with same findings
2537
+ Output: Single entry for "1.5" with:
2538
+ {
2539
+ '1.5': {
2540
+ 'findings': [...deduplicated findings...],
2541
+ 'member_igs': ['IG1', 'IG2', 'IG3'],
2542
+ 'config_rule_name': 'root-account-hardware-mfa-enabled',
2543
+ 'title': 'Root Account Hardware MFA'
2544
+ }
2545
+ }
2546
+
2547
+ Deduplication Strategy:
2548
+ - Uses (resource_id, control_id, region) tuple as unique key
2549
+ - Prevents same resource from appearing multiple times
2550
+ - Preserves all unique findings across IGs
2551
+
2552
+ Sorting:
2553
+ - Controls are sorted alphanumerically (1.1, 1.2, ..., 1.10, 2.1, ...)
2554
+ - Uses _sort_control_id() helper for proper numeric sorting
2555
+
2556
+ Notes:
2557
+ - Eliminates "IG1 Detailed Findings", "IG2 Detailed Findings" subsections
2558
+ - Each control appears once with IG membership indicators
2559
+ - Improves report readability and reduces redundancy
2560
+ """
2561
+ consolidated = {}
2562
+ seen_findings = set() # Track (resource_id, control_id, region) tuples for deduplication
2563
+
2564
+ for ig_name, ig_data in implementation_groups.items():
2565
+ for control_id, control_data in ig_data.get('controls', {}).items():
2566
+ # Initialize control entry if not exists
2567
+ if control_id not in consolidated:
2568
+ consolidated[control_id] = {
2569
+ 'findings': [],
2570
+ 'member_igs': [],
2571
+ 'config_rule_name': control_data.get('config_rule_name', ''),
2572
+ 'title': control_data.get('title', f'CIS Control {control_id}')
2573
+ }
2574
+
2575
+ # Track which IGs include this control
2576
+ consolidated[control_id]['member_igs'].append(ig_name)
2577
+
2578
+ # Add non-compliant findings (deduplicated)
2579
+ for finding in control_data.get('non_compliant_findings', []):
2580
+ finding_key = (finding.get('resource_id', ''), control_id, finding.get('region', ''))
2581
+ if finding_key not in seen_findings:
2582
+ consolidated[control_id]['findings'].append(finding)
2583
+ seen_findings.add(finding_key)
2584
+
2585
+ # Sort by control ID using alphanumeric sorting
2586
+ return dict(sorted(consolidated.items(), key=lambda x: self._sort_control_id(x[0])))
2587
+
2588
+ def _get_control_ig_membership(self, control_id: str, implementation_groups: Dict[str, Any]) -> List[str]:
2589
+ """Determine which Implementation Groups include a specific control.
2590
+
2591
+ Checks all Implementation Groups (IG1, IG2, IG3) to identify which ones
2592
+ contain the specified control, enabling display of IG membership badges.
2593
+
2594
+ Args:
2595
+ control_id: Control identifier (e.g., "1.5", "2.1")
2596
+ implementation_groups: All IG data from the assessment
2597
+
2598
+ Returns:
2599
+ List of IG names that include this control, in order.
2600
+ Examples:
2601
+ - ["IG1", "IG2", "IG3"] for a control in all IGs
2602
+ - ["IG1", "IG2"] for a control in IG1 and IG2 only
2603
+ - ["IG3"] for a control unique to IG3
2604
+ - [] for a control not found in any IG
2605
+
2606
+ Examples:
2607
+ >>> _get_control_ig_membership("1.5", implementation_groups)
2608
+ ["IG1", "IG2", "IG3"] # Control 1.5 is in all IGs
2609
+
2610
+ >>> _get_control_ig_membership("5.2", implementation_groups)
2611
+ ["IG2", "IG3"] # Control 5.2 is only in IG2 and IG3
2612
+
2613
+ Notes:
2614
+ - Used to display IG membership badges in Detailed Findings section
2615
+ - Helps users understand which IGs require remediation for each control
2616
+ - Checks IGs in order: IG1, IG2, IG3
2617
+ """
2618
+ member_igs = []
2619
+ for ig_name in ['IG1', 'IG2', 'IG3']:
2620
+ if ig_name in implementation_groups:
2621
+ if control_id in implementation_groups[ig_name].get('controls', {}):
2622
+ member_igs.append(ig_name)
2623
+ return member_igs
2624
+
2625
+ def _sort_control_id(self, control_id: str) -> tuple:
2626
+ """Helper for alphanumeric sorting of control IDs.
2627
+
2628
+ Converts control IDs like "1.1", "1.10", "2.1" into tuples of integers
2629
+ for proper alphanumeric sorting. This ensures controls are displayed in
2630
+ the correct order (1.1, 1.2, ..., 1.9, 1.10, 2.1, ...) rather than
2631
+ lexicographic order (1.1, 1.10, 1.2, ...).
2632
+
2633
+ Args:
2634
+ control_id: Control identifier (e.g., "1.1", "1.10", "2.1")
2635
+
2636
+ Returns:
2637
+ Tuple of integers for sorting (e.g., (1, 1), (1, 10), (2, 1))
2638
+ Returns (0, 0) for non-standard control IDs as fallback
2639
+
2640
+ Examples:
2641
+ >>> _sort_control_id("1.1")
2642
+ (1, 1)
2643
+
2644
+ >>> _sort_control_id("1.10")
2645
+ (1, 10)
2646
+
2647
+ >>> _sort_control_id("2.1")
2648
+ (2, 1)
2649
+
2650
+ >>> _sort_control_id("invalid")
2651
+ (0, 0) # Fallback for non-standard IDs
2652
+
2653
+ Sorting Behavior:
2654
+ Without this helper:
2655
+ ["1.1", "1.10", "1.2", "2.1"] # Incorrect lexicographic order
2656
+
2657
+ With this helper:
2658
+ ["1.1", "1.2", "1.10", "2.1"] # Correct numeric order
2659
+
2660
+ Notes:
2661
+ - Used in _consolidate_findings_by_control() for sorting
2662
+ - Handles multi-level control IDs (e.g., "1.2.3" -> (1, 2, 3))
2663
+ - Gracefully handles malformed control IDs
2664
+ """
2665
+ try:
2666
+ # Split by '.' and convert to integers
2667
+ parts = control_id.split('.')
2668
+ return tuple(int(part) for part in parts)
2669
+ except (ValueError, AttributeError):
2670
+ # Fallback for non-standard control IDs
2671
+ return (0, 0)