fix(threatscore): improve the way ThreatScore is calculated (#8582)

Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2025-10-09 11:50:10 +02:00
committed by GitHub
parent b630234cdf
commit 1a7f52fc9c
12 changed files with 198 additions and 21 deletions
+1
View File
@@ -8,6 +8,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Default JWT keys are generated and stored if they are missing from configuration [(#8655)](https://github.com/prowler-cloud/prowler/pull/8655)
- `compliance_name` for each compliance [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920)
- API Key support [(#8805)](https://github.com/prowler-cloud/prowler/pull/8805)
- Support for `passed_findings` and `total_findings` fields in compliance requirement overview for accurate Prowler ThreatScore calculation [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
### Changed
- Now the MANAGE_ACCOUNT permission is required to modify or read user permissions instead of MANAGE_USERS [(#8281)](https://github.com/prowler-cloud/prowler/pull/8281)
@@ -0,0 +1,21 @@
# Generated by Django 5.1.12 on 2025-10-07 10:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0048_api_key"),
]
operations = [
migrations.AddField(
model_name="compliancerequirementoverview",
name="passed_findings",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="compliancerequirementoverview",
name="total_findings",
field=models.IntegerField(default=0),
),
]
+2
View File
@@ -1293,6 +1293,8 @@ class ComplianceRequirementOverview(RowLevelSecurityProtectedModel):
passed_checks = models.IntegerField(default=0)
failed_checks = models.IntegerField(default=0)
total_checks = models.IntegerField(default=0)
passed_findings = models.IntegerField(default=0)
total_findings = models.IntegerField(default=0)
scan = models.ForeignKey(
Scan,
+11
View File
@@ -1991,6 +1991,17 @@ class ComplianceOverviewDetailSerializer(serializers.Serializer):
resource_name = "compliance-requirements-details"
class ComplianceOverviewDetailThreatscoreSerializer(ComplianceOverviewDetailSerializer):
"""
Serializer for detailed compliance requirement information for Threatscore.
Includes additional fields specific to the Threatscore framework.
"""
passed_findings = serializers.IntegerField()
total_findings = serializers.IntegerField()
class ComplianceOverviewAttributesSerializer(serializers.Serializer):
id = serializers.CharField()
compliance_name = serializers.CharField()
+19 -2
View File
@@ -142,6 +142,7 @@ from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin
from api.v1.serializers import (
ComplianceOverviewAttributesSerializer,
ComplianceOverviewDetailSerializer,
ComplianceOverviewDetailThreatscoreSerializer,
ComplianceOverviewMetadataSerializer,
ComplianceOverviewSerializer,
FindingDynamicFilterSerializer,
@@ -3438,7 +3439,12 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
all_requirements = (
filtered_queryset.values(
"requirement_id", "framework", "version", "description"
"requirement_id",
"framework",
"version",
"description",
"passed_findings",
"total_findings",
)
.distinct()
.annotate(
@@ -3463,6 +3469,8 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
total_instances = requirement["total_instances"]
passed_count = passed_counts.get(requirement_id, 0)
is_manual = requirement["manual_count"] == total_instances
passed_findings = requirement["passed_findings"]
total_findings = requirement["total_findings"]
if is_manual:
requirement_status = "MANUAL"
elif passed_count == total_instances:
@@ -3477,10 +3485,19 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
"version": requirement["version"],
"description": requirement["description"],
"status": requirement_status,
"passed_findings": passed_findings,
"total_findings": total_findings,
}
)
serializer = self.get_serializer(requirements_summary, many=True)
# Use different serializer for threatscore framework
if "threatscore" not in compliance_id:
serializer = self.get_serializer(requirements_summary, many=True)
else:
serializer = ComplianceOverviewDetailThreatscoreSerializer(
requirements_summary, many=True
)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_name="attributes")
+39 -2
View File
@@ -590,7 +590,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
# Get check status data by region from findings
findings = (
Finding.all_objects.filter(scan_id=scan_id, muted=False)
.only("id", "check_id", "status")
.only("id", "check_id", "status", "compliance")
.prefetch_related(
Prefetch(
"resources",
@@ -601,7 +601,9 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
.iterator(chunk_size=1000)
)
findings_count_by_compliance = {}
check_status_by_region = {}
modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0"
with rls_transaction(tenant_id):
for finding in findings:
for resource in finding.small_resources:
@@ -609,6 +611,27 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
current_status = check_status_by_region.setdefault(region, {})
if current_status.get(finding.check_id) != "FAIL":
current_status[finding.check_id] = finding.status
if modeled_threatscore_compliance_id in finding.compliance:
for requirement_id in finding.compliance[
modeled_threatscore_compliance_id
]:
compliance_key = findings_count_by_compliance.setdefault(
region, {}
).setdefault(
modeled_threatscore_compliance_id.lower().replace(
"-", ""
),
{},
)
if requirement_id not in compliance_key:
compliance_key[requirement_id] = {
"total": 0,
"pass": 0,
}
compliance_key[requirement_id]["total"] += 1
if finding.status == "PASS":
compliance_key[requirement_id]["pass"] += 1
try:
# Try to get regions from provider
@@ -644,6 +667,13 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
compliance_requirement_objects = []
for region, compliance_data in compliance_overview_by_region.items():
for compliance_id, compliance in compliance_data.items():
modeled_framework = (
compliance["framework"].lower().replace("-", "").replace("_", "")
)
modeled_version = (
compliance["version"].lower().replace("-", "").replace("_", "")
)
modeled_compliance_id = f"{modeled_framework}{modeled_version}"
# Create an overview record for each requirement within each compliance framework
for requirement_id, requirement in compliance["requirements"].items():
compliance_requirement_objects.append(
@@ -660,9 +690,16 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
failed_checks=requirement["checks_status"]["fail"],
total_checks=requirement["checks_status"]["total"],
requirement_status=requirement["status"],
passed_findings=findings_count_by_compliance.get(region, {})
.get(modeled_compliance_id, {})
.get(requirement_id, {})
.get("pass", 0),
total_findings=findings_count_by_compliance.get(region, {})
.get(modeled_compliance_id, {})
.get(requirement_id, {})
.get("total", 0),
)
)
# Bulk create requirement records
create_objects_in_batches(
tenant_id, ComplianceRequirementOverview, compliance_requirement_objects
+2 -1
View File
@@ -34,6 +34,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### Fixed
- Fix SNS topics showing empty AWS_ResourceID in Quick Inventory output [(#8762)](https://github.com/prowler-cloud/prowler/issues/8762)
- Fix HTML Markdown output for long strings [(#8803)](https://github.com/prowler-cloud/prowler/pull/8803)
- Prowler ThreatScore scoring calculation CLI [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
---
@@ -438,4 +439,4 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Handle projects without ID in GCP [(#7496)](https://github.com/prowler-cloud/prowler/pull/7496)
- Restore packages location in PyProject [(#7510)](https://github.com/prowler-cloud/prowler/pull/7510)
---
---
@@ -2,6 +2,7 @@ from colorama import Fore, Style
from tabulate import tabulate
from prowler.config.config import orange_color
from prowler.lib.check.compliance_models import Compliance
def get_prowler_threatscore_table(
@@ -23,9 +24,12 @@ def get_prowler_threatscore_table(
fail_count = []
muted_count = []
pillars = {}
generic_score = 0
max_generic_score = 0
counted_findings_generic = []
score_per_pillar = {}
max_score_per_pillar = {}
counted_findings = []
counted_findings_per_pillar = {}
for index, finding in enumerate(findings):
check = bulk_checks_metadata[finding.check_metadata.CheckID]
check_compliances = check.Compliance
@@ -39,12 +43,17 @@ def get_prowler_threatscore_table(
[
pillar in score_per_pillar.keys(),
pillar in max_score_per_pillar.keys(),
pillar in counted_findings_per_pillar.keys(),
]
):
score_per_pillar[pillar] = 0
max_score_per_pillar[pillar] = 0
counted_findings_per_pillar[pillar] = []
if index not in counted_findings:
if (
index not in counted_findings_per_pillar[pillar]
and not finding.muted
):
if finding.status == "PASS":
score_per_pillar[pillar] += (
attribute.LevelOfRisk * attribute.Weight
@@ -52,7 +61,7 @@ def get_prowler_threatscore_table(
max_score_per_pillar[pillar] += (
attribute.LevelOfRisk * attribute.Weight
)
counted_findings.append(index)
counted_findings_per_pillar[pillar].append(index)
if pillar not in pillars:
pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0}
@@ -69,6 +78,27 @@ def get_prowler_threatscore_table(
pass_count.append(index)
pillars[pillar]["PASS"] += 1
# Generic score
if index not in counted_findings_generic and not finding.muted:
if finding.status == "PASS":
generic_score += (
attribute.LevelOfRisk * attribute.Weight
)
max_generic_score += (
attribute.LevelOfRisk * attribute.Weight
)
counted_findings_generic.append(index)
no_findings_pillars = []
bulk_compliance = Compliance.get_bulk(provider=compliance.Provider.lower()).get(
compliance_framework
)
for requirement in bulk_compliance.Requirements:
for attribute in requirement.Attributes:
pillar = attribute.Section
if pillar not in pillars.keys() and pillar not in no_findings_pillars:
no_findings_pillars.append(pillar)
pillars = dict(sorted(pillars.items()))
for pillar in pillars:
pillar_table["Provider"].append(compliance.Provider)
@@ -88,6 +118,16 @@ def get_prowler_threatscore_table(
f"{orange_color}{pillars[pillar]['Muted']}{Style.RESET_ALL}"
)
for pillar in no_findings_pillars:
pillar_table["Provider"].append(compliance.Provider)
pillar_table["Pillar"].append(pillar)
pillar_table["Score"].append(f"{Style.BRIGHT}{Fore.GREEN}100%{Style.RESET_ALL}")
pillar_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}")
pillar_table["Muted"].append(f"{orange_color}0{Style.RESET_ALL}")
# Sort table by pillars
pillar_table["Pillar"] = sorted(pillar_table["Pillar"])
if (
len(fail_count) + len(pass_count) + len(muted_count) > 1
): # If there are no resources, don't print the compliance table
@@ -108,7 +148,9 @@ def get_prowler_threatscore_table(
print(
f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:"
)
print(
f"\nGeneric Threat Score: {generic_score / max_generic_score * 100:.2f}%"
)
print(
tabulate(
pillar_table,
+4
View File
@@ -26,6 +26,10 @@ All notable changes to the **Prowler UI** are documented in this file.
- Migrated from `useFormState` to `useActionState` for React 19 compatibility [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- References display in findings detail page now shows as a proper bulleted list [(#8793)](https://github.com/prowler-cloud/prowler/pull/8793)
### 🐞 Fixed
- ThreatScore for each pillar in Prowler ThreatScore specific view [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
---
## [1.12.4] (Prowler v5.12.4)
@@ -54,6 +54,25 @@ export const ThreatCustomDetails = ({
conditional={true}
/>
)}
{typeof requirement.passedFindings === "number" &&
typeof requirement.totalFindings === "number" && (
<>
<ComplianceBadge
label="Findings"
value={`${requirement.passedFindings}/${requirement.totalFindings}`}
color="blue"
/>
{requirement.totalFindings > 0 && (
<ComplianceBadge
label="Pass Rate"
value={`${Math.round((requirement.passedFindings / requirement.totalFindings) * 100)}%`}
color="green"
conditional={true}
/>
)}
</>
)}
</ComplianceBadgeContainer>
{requirement.additionalInformation && (
+31 -12
View File
@@ -52,6 +52,8 @@ export const mapComplianceData = (
const weight = attrs.Weight;
const attributeDescription = attrs.AttributeDescription;
const additionalInformation = attrs.AdditionalInformation;
const passedFindings = requirementData.attributes.passed_findings || 0;
const totalFindings = requirementData.attributes.total_findings || 0;
// Calculate score: if PASS = levelOfRisk * weight, if FAIL = 0
const score = status === "PASS" ? levelOfRisk * weight : 0;
@@ -81,6 +83,8 @@ export const mapComplianceData = (
score: score,
attributeDescription: attributeDescription,
additionalInformation: additionalInformation,
passedFindings: passedFindings,
totalFindings: totalFindings,
};
control.requirements.push(requirement);
@@ -97,9 +101,10 @@ export const mapComplianceData = (
category.fail = 0;
category.manual = 0;
// Calculate total score for this section and maximum possible score
let totalSectionScore = 0;
let maxPossibleSectionScore = 0;
// Calculate ThreatScore using the new formula
let numerator = 0;
let denominator = 0;
let hasFindings = false;
category.controls.forEach((control) => {
control.pass = 0;
@@ -109,13 +114,25 @@ export const mapComplianceData = (
control.requirements.forEach((requirement) => {
updateCounters(control, requirement.status);
// Add to total section score (actual score obtained)
totalSectionScore += (requirement.score as number) || 0;
// Extract values for ThreatScore calculation
const pass_i = (requirement.passedFindings as number) || 0;
const total_i = (requirement.totalFindings as number) || 0;
// Add to maximum possible score (weight * levelOfRisk for each requirement)
// Skip if no findings (avoid division by zero)
if (total_i === 0) return;
hasFindings = true;
const rate_i = pass_i / total_i;
const weight_i = (requirement.weight as number) || 1;
const levelOfRisk = (requirement.levelOfRisk as number) || 0;
const weight = (requirement.weight as number) || 0;
maxPossibleSectionScore += levelOfRisk * weight;
// Map levelOfRisk to risk factor (rfac_i)
// Formula: rfac_i = 1 + (k * levelOfRisk) where k = 0.25
const rfac_i = 1 + 0.25 * levelOfRisk;
// Accumulate weighted values
numerator += rate_i * total_i * weight_i * rfac_i;
denominator += total_i * weight_i * rfac_i;
});
category.pass += control.pass;
@@ -123,10 +140,12 @@ export const mapComplianceData = (
category.manual += control.manual;
});
// Calculate percentualScore for this section: (suma de scores obtenidos / suma de weight * levelOfRisk) * 100
const percentualScore =
maxPossibleSectionScore > 0
? (totalSectionScore / maxPossibleSectionScore) * 100
// Calculate ThreatScore (percentualScore) for this section
// If no findings exist, consider it 100% (PASS)
const percentualScore = !hasFindings
? 100
: denominator > 0
? (numerator / denominator) * 100
: 0;
// Add percentualScore to category (we can extend the type or use a custom property)
+3
View File
@@ -189,6 +189,9 @@ export interface RequirementItemData {
version: string;
description: string;
status: RequirementStatus;
// For Threat compliance:
passed_findings?: number;
total_findings?: number;
};
}