aws-cis-controls-assessment 1.1.4__py3-none-any.whl → 1.2.2__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.
- aws_cis_assessment/__init__.py +4 -4
- aws_cis_assessment/config/rules/cis_controls_ig1.yaml +365 -2
- aws_cis_assessment/controls/base_control.py +106 -24
- aws_cis_assessment/controls/ig1/__init__.py +144 -15
- aws_cis_assessment/controls/ig1/control_4_1.py +4 -4
- aws_cis_assessment/controls/ig1/control_access_analyzer.py +198 -0
- aws_cis_assessment/controls/ig1/control_access_asset_mgmt.py +360 -0
- aws_cis_assessment/controls/ig1/control_access_control.py +323 -0
- aws_cis_assessment/controls/ig1/control_backup_security.py +579 -0
- aws_cis_assessment/controls/ig1/control_cloudfront_logging.py +215 -0
- aws_cis_assessment/controls/ig1/control_configuration_mgmt.py +407 -0
- aws_cis_assessment/controls/ig1/control_data_classification.py +255 -0
- aws_cis_assessment/controls/ig1/control_dynamodb_encryption.py +279 -0
- aws_cis_assessment/controls/ig1/control_ebs_encryption.py +177 -0
- aws_cis_assessment/controls/ig1/control_efs_encryption.py +243 -0
- aws_cis_assessment/controls/ig1/control_elb_logging.py +195 -0
- aws_cis_assessment/controls/ig1/control_guardduty.py +156 -0
- aws_cis_assessment/controls/ig1/control_inspector.py +184 -0
- aws_cis_assessment/controls/ig1/control_inventory.py +511 -0
- aws_cis_assessment/controls/ig1/control_macie.py +165 -0
- aws_cis_assessment/controls/ig1/control_messaging_encryption.py +419 -0
- aws_cis_assessment/controls/ig1/control_mfa.py +485 -0
- aws_cis_assessment/controls/ig1/control_network_security.py +194 -619
- aws_cis_assessment/controls/ig1/control_patch_management.py +626 -0
- aws_cis_assessment/controls/ig1/control_rds_encryption.py +228 -0
- aws_cis_assessment/controls/ig1/control_s3_encryption.py +383 -0
- aws_cis_assessment/controls/ig1/control_tls_ssl.py +556 -0
- aws_cis_assessment/controls/ig1/control_version_mgmt.py +337 -0
- aws_cis_assessment/controls/ig1/control_vpc_flow_logs.py +205 -0
- aws_cis_assessment/controls/ig1/control_waf_logging.py +226 -0
- aws_cis_assessment/core/assessment_engine.py +160 -11
- aws_cis_assessment/core/aws_client_factory.py +17 -5
- aws_cis_assessment/core/models.py +20 -1
- aws_cis_assessment/core/scoring_engine.py +102 -1
- aws_cis_assessment/reporters/base_reporter.py +58 -13
- aws_cis_assessment/reporters/html_reporter.py +186 -9
- aws_cis_controls_assessment-1.2.2.dist-info/METADATA +320 -0
- {aws_cis_controls_assessment-1.1.4.dist-info → aws_cis_controls_assessment-1.2.2.dist-info}/RECORD +44 -20
- docs/developer-guide.md +204 -5
- docs/user-guide.md +137 -4
- aws_cis_controls_assessment-1.1.4.dist-info/METADATA +0 -404
- {aws_cis_controls_assessment-1.1.4.dist-info → aws_cis_controls_assessment-1.2.2.dist-info}/WHEEL +0 -0
- {aws_cis_controls_assessment-1.1.4.dist-info → aws_cis_controls_assessment-1.2.2.dist-info}/entry_points.txt +0 -0
- {aws_cis_controls_assessment-1.1.4.dist-info → aws_cis_controls_assessment-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {aws_cis_controls_assessment-1.1.4.dist-info → aws_cis_controls_assessment-1.2.2.dist-info}/top_level.txt +0 -0
|
@@ -80,21 +80,36 @@ class ReportGenerator(ABC):
|
|
|
80
80
|
Returns:
|
|
81
81
|
Dictionary containing standardized report data
|
|
82
82
|
"""
|
|
83
|
-
# Calculate additional metrics
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
# Calculate additional metrics - deduplicate resources across IGs
|
|
84
|
+
# Since IG2 includes IG1 and IG3 includes IG1+IG2, we need to count unique resources
|
|
85
|
+
# Use IG1 as the source of truth since it contains all unique evaluations
|
|
86
|
+
# (controls in higher IGs are the same controls, just evaluated again)
|
|
87
|
+
|
|
88
|
+
# Collect unique resources by (resource_id, resource_type, region, config_rule_name)
|
|
89
|
+
unique_resources = {}
|
|
90
|
+
|
|
91
|
+
for ig_name, ig_score in assessment_result.ig_scores.items():
|
|
92
|
+
for control_id, control in ig_score.control_scores.items():
|
|
93
|
+
for finding in control.findings:
|
|
94
|
+
# Create unique key for each resource evaluation
|
|
95
|
+
resource_key = (
|
|
96
|
+
finding.resource_id,
|
|
97
|
+
finding.resource_type,
|
|
98
|
+
finding.region,
|
|
99
|
+
finding.config_rule_name
|
|
100
|
+
)
|
|
101
|
+
# Store the finding (last one wins, but they should be identical)
|
|
102
|
+
unique_resources[resource_key] = finding
|
|
103
|
+
|
|
104
|
+
# Now count from unique resources
|
|
105
|
+
total_resources = len(unique_resources)
|
|
89
106
|
total_compliant = sum(
|
|
90
|
-
|
|
91
|
-
|
|
107
|
+
1 for finding in unique_resources.values()
|
|
108
|
+
if finding.compliance_status.value == 'COMPLIANT'
|
|
92
109
|
)
|
|
93
|
-
|
|
94
110
|
total_non_compliant = sum(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
for ig in assessment_result.ig_scores.values()
|
|
111
|
+
1 for finding in unique_resources.values()
|
|
112
|
+
if finding.compliance_status.value == 'NON_COMPLIANT'
|
|
98
113
|
)
|
|
99
114
|
|
|
100
115
|
# Prepare standardized data structure
|
|
@@ -119,7 +134,8 @@ class ReportGenerator(ABC):
|
|
|
119
134
|
'compliant_resources': total_compliant,
|
|
120
135
|
'non_compliant_resources': total_non_compliant,
|
|
121
136
|
'top_risk_areas': compliance_summary.top_risk_areas,
|
|
122
|
-
'compliance_trend': compliance_summary.compliance_trend
|
|
137
|
+
'compliance_trend': compliance_summary.compliance_trend,
|
|
138
|
+
'coverage_metrics': self._prepare_coverage_metrics_data(compliance_summary.coverage_metrics)
|
|
123
139
|
},
|
|
124
140
|
'implementation_groups': self._prepare_ig_data(assessment_result.ig_scores),
|
|
125
141
|
'remediation_priorities': self._prepare_remediation_data(compliance_summary.remediation_priorities),
|
|
@@ -228,6 +244,35 @@ class ReportGenerator(ABC):
|
|
|
228
244
|
|
|
229
245
|
return remediation_data
|
|
230
246
|
|
|
247
|
+
def _prepare_coverage_metrics_data(self, coverage_metrics: Dict[str, Any]) -> Dict[str, Any]:
|
|
248
|
+
"""Prepare coverage metrics data for reporting.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
coverage_metrics: Dictionary of CoverageMetrics objects by IG
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Dictionary containing coverage metrics structured for reporting
|
|
255
|
+
"""
|
|
256
|
+
coverage_data = {}
|
|
257
|
+
|
|
258
|
+
for ig_name, metrics in coverage_metrics.items():
|
|
259
|
+
# Handle both CoverageMetrics objects and dictionaries
|
|
260
|
+
if hasattr(metrics, '__dict__'):
|
|
261
|
+
metrics_dict = metrics.__dict__
|
|
262
|
+
else:
|
|
263
|
+
metrics_dict = metrics
|
|
264
|
+
|
|
265
|
+
coverage_data[ig_name] = {
|
|
266
|
+
'implementation_group': metrics_dict.get('implementation_group', ig_name),
|
|
267
|
+
'total_safeguards': metrics_dict.get('total_safeguards', 0),
|
|
268
|
+
'covered_safeguards': metrics_dict.get('covered_safeguards', 0),
|
|
269
|
+
'coverage_percentage': metrics_dict.get('coverage_percentage', 0.0),
|
|
270
|
+
'implemented_rules': metrics_dict.get('implemented_rules', 0),
|
|
271
|
+
'safeguard_details': metrics_dict.get('safeguard_details', {})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return coverage_data
|
|
275
|
+
|
|
231
276
|
def _prepare_findings_data(self, ig_scores: Dict[str, IGScore]) -> Dict[str, Any]:
|
|
232
277
|
"""Prepare detailed findings data for reporting.
|
|
233
278
|
|
|
@@ -173,6 +173,12 @@ class HTMLReporter(ReportGenerator):
|
|
|
173
173
|
# Add chart data for Implementation Groups
|
|
174
174
|
html_data["chart_data"] = self._prepare_chart_data(html_data)
|
|
175
175
|
|
|
176
|
+
# Add coverage metrics if available
|
|
177
|
+
if "coverage_metrics" in report_data.get("executive_summary", {}):
|
|
178
|
+
html_data["coverage_metrics"] = self._prepare_coverage_metrics(
|
|
179
|
+
report_data["executive_summary"]["coverage_metrics"]
|
|
180
|
+
)
|
|
181
|
+
|
|
176
182
|
# Enhance Implementation Group data with visual elements
|
|
177
183
|
for ig_name, ig_data in html_data["implementation_groups"].items():
|
|
178
184
|
ig_data["status_color"] = self._get_status_color(ig_data["compliance_percentage"])
|
|
@@ -483,6 +489,76 @@ class HTMLReporter(ReportGenerator):
|
|
|
483
489
|
font-size: 0.8em;
|
|
484
490
|
}
|
|
485
491
|
|
|
492
|
+
/* Coverage Metrics Section */
|
|
493
|
+
.coverage-metrics-section {
|
|
494
|
+
margin: 30px 0;
|
|
495
|
+
padding: 20px;
|
|
496
|
+
background: #f8f9fa;
|
|
497
|
+
border-radius: 10px;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.coverage-intro {
|
|
501
|
+
color: #666;
|
|
502
|
+
margin-bottom: 20px;
|
|
503
|
+
font-size: 0.95em;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
.coverage-grid {
|
|
507
|
+
display: grid;
|
|
508
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
509
|
+
gap: 20px;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.coverage-card {
|
|
513
|
+
background: white;
|
|
514
|
+
border-radius: 10px;
|
|
515
|
+
padding: 20px;
|
|
516
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
517
|
+
border-left: 5px solid #3498db;
|
|
518
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.coverage-card:hover {
|
|
522
|
+
transform: translateY(-2px);
|
|
523
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.coverage-card.excellent { border-left-color: #27ae60; }
|
|
527
|
+
.coverage-card.good { border-left-color: #2ecc71; }
|
|
528
|
+
.coverage-card.fair { border-left-color: #f39c12; }
|
|
529
|
+
.coverage-card.poor { border-left-color: #e67e22; }
|
|
530
|
+
.coverage-card.critical { border-left-color: #e74c3c; }
|
|
531
|
+
|
|
532
|
+
.coverage-card h4 {
|
|
533
|
+
margin: 0 0 15px 0;
|
|
534
|
+
color: #2c3e50;
|
|
535
|
+
font-size: 1.2em;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.coverage-value {
|
|
539
|
+
font-size: 2.5em;
|
|
540
|
+
font-weight: bold;
|
|
541
|
+
color: #2c3e50;
|
|
542
|
+
margin-bottom: 15px;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.coverage-details {
|
|
546
|
+
color: #666;
|
|
547
|
+
font-size: 0.9em;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.coverage-details p {
|
|
551
|
+
margin: 8px 0;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.coverage-description {
|
|
555
|
+
margin-top: 12px;
|
|
556
|
+
padding-top: 12px;
|
|
557
|
+
border-top: 1px solid #e0e0e0;
|
|
558
|
+
font-style: italic;
|
|
559
|
+
color: #555;
|
|
560
|
+
}
|
|
561
|
+
|
|
486
562
|
/* Implementation Groups */
|
|
487
563
|
.ig-section {
|
|
488
564
|
margin-bottom: 40px;
|
|
@@ -1785,13 +1861,16 @@ class HTMLReporter(ReportGenerator):
|
|
|
1785
1861
|
<span>{ig_value:.1f}%</span>
|
|
1786
1862
|
</div>
|
|
1787
1863
|
<div class="progress-container">
|
|
1788
|
-
<div class="progress-bar {ig_status}" data-width="{ig_value}">
|
|
1864
|
+
<div class="progress-bar {ig_status}" data-width="{self._get_display_width(ig_value)}">
|
|
1789
1865
|
<span class="progress-text">{ig_value:.1f}%</span>
|
|
1790
1866
|
</div>
|
|
1791
1867
|
</div>
|
|
1792
1868
|
</div>
|
|
1793
1869
|
"""
|
|
1794
1870
|
|
|
1871
|
+
# Generate coverage metrics section
|
|
1872
|
+
coverage_section = self._generate_coverage_metrics_section(html_data.get("coverage_metrics", {}))
|
|
1873
|
+
|
|
1795
1874
|
# Generate charts section
|
|
1796
1875
|
charts_section = ""
|
|
1797
1876
|
if self.include_charts:
|
|
@@ -1817,6 +1896,8 @@ class HTMLReporter(ReportGenerator):
|
|
|
1817
1896
|
{ig_progress}
|
|
1818
1897
|
</div>
|
|
1819
1898
|
|
|
1899
|
+
{coverage_section}
|
|
1900
|
+
|
|
1820
1901
|
{charts_section}
|
|
1821
1902
|
</section>
|
|
1822
1903
|
"""
|
|
@@ -1878,7 +1959,7 @@ class HTMLReporter(ReportGenerator):
|
|
|
1878
1959
|
</div>
|
|
1879
1960
|
{inheritance_indicator}
|
|
1880
1961
|
<div class="progress-container">
|
|
1881
|
-
<div class="progress-bar {status_class}" data-width="{control_data['compliance_percentage']}">
|
|
1962
|
+
<div class="progress-bar {status_class}" data-width="{self._get_display_width(control_data['compliance_percentage'])}">
|
|
1882
1963
|
<span class="progress-text">{control_data['compliance_percentage']:.1f}%</span>
|
|
1883
1964
|
</div>
|
|
1884
1965
|
</div>
|
|
@@ -2025,6 +2106,34 @@ class HTMLReporter(ReportGenerator):
|
|
|
2025
2106
|
"riskDistribution": risk_distribution
|
|
2026
2107
|
}
|
|
2027
2108
|
|
|
2109
|
+
def _prepare_coverage_metrics(self, coverage_metrics: Dict[str, Any]) -> Dict[str, Any]:
|
|
2110
|
+
"""Prepare coverage metrics for HTML display.
|
|
2111
|
+
|
|
2112
|
+
Args:
|
|
2113
|
+
coverage_metrics: Coverage metrics from ComplianceSummary
|
|
2114
|
+
|
|
2115
|
+
Returns:
|
|
2116
|
+
Formatted coverage metrics dictionary
|
|
2117
|
+
"""
|
|
2118
|
+
formatted_metrics = {}
|
|
2119
|
+
|
|
2120
|
+
for ig_name, metrics in coverage_metrics.items():
|
|
2121
|
+
# Handle both CoverageMetrics objects and dictionaries
|
|
2122
|
+
if hasattr(metrics, '__dict__'):
|
|
2123
|
+
metrics_dict = metrics.__dict__
|
|
2124
|
+
else:
|
|
2125
|
+
metrics_dict = metrics
|
|
2126
|
+
|
|
2127
|
+
formatted_metrics[ig_name] = {
|
|
2128
|
+
'coverage_percentage': metrics_dict.get('coverage_percentage', 0),
|
|
2129
|
+
'total_safeguards': metrics_dict.get('total_safeguards', 0),
|
|
2130
|
+
'covered_safeguards': metrics_dict.get('covered_safeguards', 0),
|
|
2131
|
+
'implemented_rules': metrics_dict.get('implemented_rules', 0),
|
|
2132
|
+
'safeguard_details': metrics_dict.get('safeguard_details', {})
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
return formatted_metrics
|
|
2136
|
+
|
|
2028
2137
|
def _build_navigation_structure(self, html_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
2029
2138
|
"""Build navigation structure for the report.
|
|
2030
2139
|
|
|
@@ -2079,6 +2188,20 @@ class HTMLReporter(ReportGenerator):
|
|
|
2079
2188
|
else:
|
|
2080
2189
|
return "#e74c3c" # Red
|
|
2081
2190
|
|
|
2191
|
+
def _get_display_width(self, actual_percentage: float) -> float:
|
|
2192
|
+
"""Calculate display width for progress bars.
|
|
2193
|
+
|
|
2194
|
+
Ensures minimum 5% width for visibility when actual percentage is 0%.
|
|
2195
|
+
This prevents progress bars from being invisible for non-compliant controls.
|
|
2196
|
+
|
|
2197
|
+
Args:
|
|
2198
|
+
actual_percentage: Actual compliance percentage (0-100)
|
|
2199
|
+
|
|
2200
|
+
Returns:
|
|
2201
|
+
Display width with minimum 5% for visibility
|
|
2202
|
+
"""
|
|
2203
|
+
return max(5.0, actual_percentage)
|
|
2204
|
+
|
|
2082
2205
|
def _get_status_class(self, compliance_percentage: float) -> str:
|
|
2083
2206
|
"""Get CSS status class based on compliance percentage."""
|
|
2084
2207
|
if compliance_percentage >= 95.0:
|
|
@@ -2193,9 +2316,9 @@ class HTMLReporter(ReportGenerator):
|
|
|
2193
2316
|
have higher impact on the overall score.
|
|
2194
2317
|
</p>
|
|
2195
2318
|
<ul class="comparison-features">
|
|
2196
|
-
<li
|
|
2197
|
-
<li
|
|
2198
|
-
<li
|
|
2319
|
+
<li>Prioritizes critical security controls</li>
|
|
2320
|
+
<li>Prevents resource count skew</li>
|
|
2321
|
+
<li>Guides remediation priorities</li>
|
|
2199
2322
|
</ul>
|
|
2200
2323
|
</div>
|
|
2201
2324
|
|
|
@@ -2207,15 +2330,69 @@ class HTMLReporter(ReportGenerator):
|
|
|
2207
2330
|
All rules treated equally regardless of security criticality.
|
|
2208
2331
|
</p>
|
|
2209
2332
|
<ul class="comparison-features">
|
|
2210
|
-
<li
|
|
2211
|
-
<li
|
|
2212
|
-
<li
|
|
2333
|
+
<li>Simple and straightforward</li>
|
|
2334
|
+
<li>Easy to audit</li>
|
|
2335
|
+
<li>Resource-level tracking</li>
|
|
2213
2336
|
</ul>
|
|
2214
2337
|
</div>
|
|
2215
2338
|
</div>
|
|
2216
2339
|
</div>
|
|
2217
2340
|
"""
|
|
2218
2341
|
|
|
2342
|
+
def _generate_coverage_metrics_section(self, coverage_metrics: Dict[str, Any]) -> str:
|
|
2343
|
+
"""Generate CIS Controls coverage metrics section.
|
|
2344
|
+
|
|
2345
|
+
Args:
|
|
2346
|
+
coverage_metrics: Dictionary of coverage metrics by IG
|
|
2347
|
+
|
|
2348
|
+
Returns:
|
|
2349
|
+
HTML section displaying coverage metrics
|
|
2350
|
+
"""
|
|
2351
|
+
if not coverage_metrics:
|
|
2352
|
+
return ""
|
|
2353
|
+
|
|
2354
|
+
coverage_cards = ""
|
|
2355
|
+
for ig_name in ['IG1', 'IG2', 'IG3']:
|
|
2356
|
+
metrics = coverage_metrics.get(ig_name)
|
|
2357
|
+
if not metrics:
|
|
2358
|
+
continue
|
|
2359
|
+
|
|
2360
|
+
coverage_pct = metrics.get('coverage_percentage', 0)
|
|
2361
|
+
total_safeguards = metrics.get('total_safeguards', 0)
|
|
2362
|
+
covered_safeguards = metrics.get('covered_safeguards', 0)
|
|
2363
|
+
implemented_rules = metrics.get('implemented_rules', 0)
|
|
2364
|
+
description = metrics.get('safeguard_details', {}).get('coverage_description', '')
|
|
2365
|
+
|
|
2366
|
+
status_class = self._get_status_class(coverage_pct)
|
|
2367
|
+
|
|
2368
|
+
coverage_cards += f"""
|
|
2369
|
+
<div class="coverage-card {status_class}">
|
|
2370
|
+
<h4>{ig_name} Coverage</h4>
|
|
2371
|
+
<div class="coverage-value">{coverage_pct:.1f}%</div>
|
|
2372
|
+
<div class="coverage-details">
|
|
2373
|
+
<p><strong>{covered_safeguards}</strong> of <strong>{total_safeguards}</strong> safeguards covered</p>
|
|
2374
|
+
<p><strong>{implemented_rules}</strong> rules implemented</p>
|
|
2375
|
+
<p class="coverage-description">{description}</p>
|
|
2376
|
+
</div>
|
|
2377
|
+
</div>
|
|
2378
|
+
"""
|
|
2379
|
+
|
|
2380
|
+
if not coverage_cards:
|
|
2381
|
+
return ""
|
|
2382
|
+
|
|
2383
|
+
return f"""
|
|
2384
|
+
<div class="coverage-metrics-section">
|
|
2385
|
+
<h3>CIS Controls v8.1 Coverage</h3>
|
|
2386
|
+
<p class="coverage-intro">
|
|
2387
|
+
This assessment implements rules that cover CIS Controls v8.1 safeguards across Implementation Groups.
|
|
2388
|
+
Coverage indicates the percentage of safeguards that have at least one assessment rule.
|
|
2389
|
+
</p>
|
|
2390
|
+
<div class="coverage-grid">
|
|
2391
|
+
{coverage_cards}
|
|
2392
|
+
</div>
|
|
2393
|
+
</div>
|
|
2394
|
+
"""
|
|
2395
|
+
|
|
2219
2396
|
def set_chart_options(self, include_charts: bool = True) -> None:
|
|
2220
2397
|
"""Configure chart inclusion options.
|
|
2221
2398
|
|
|
@@ -2561,7 +2738,7 @@ class HTMLReporter(ReportGenerator):
|
|
|
2561
2738
|
type_compliance = (stats["compliant"] / stats["total"] * 100) if stats["total"] > 0 else 0
|
|
2562
2739
|
status_class = self._get_status_class(type_compliance)
|
|
2563
2740
|
# Ensure minimum width of 5% for visibility when compliance is 0%
|
|
2564
|
-
display_width =
|
|
2741
|
+
display_width = self._get_display_width(type_compliance)
|
|
2565
2742
|
|
|
2566
2743
|
resource_type_breakdown += f"""
|
|
2567
2744
|
<div class="resource-type-stat">
|