diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md
index 114fbc61c8..d6b692c110 100644
--- a/api/CHANGELOG.md
+++ b/api/CHANGELOG.md
@@ -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)
diff --git a/api/src/backend/api/migrations/0049_compliancerequirementoverview_passed_failed_findings.py b/api/src/backend/api/migrations/0049_compliancerequirementoverview_passed_failed_findings.py
new file mode 100644
index 0000000000..db4f340a53
--- /dev/null
+++ b/api/src/backend/api/migrations/0049_compliancerequirementoverview_passed_failed_findings.py
@@ -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),
+ ),
+ ]
diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py
index 0577f22e51..048f1ca7db 100644
--- a/api/src/backend/api/models.py
+++ b/api/src/backend/api/models.py
@@ -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,
diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py
index e20fb4fab1..32ebbb040d 100644
--- a/api/src/backend/api/v1/serializers.py
+++ b/api/src/backend/api/v1/serializers.py
@@ -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()
diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py
index 8097ee2321..777afac907 100644
--- a/api/src/backend/api/v1/views.py
+++ b/api/src/backend/api/v1/views.py
@@ -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")
diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py
index 311b1478c3..63183d57db 100644
--- a/api/src/backend/tasks/jobs/scan.py
+++ b/api/src/backend/tasks/jobs/scan.py
@@ -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
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index dc8f02e52e..8f6f548e45 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -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)
----
+---
\ No newline at end of file
diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py
index eb5194074e..2034ee97fa 100644
--- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py
+++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py
@@ -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,
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 79a4930edf..e0bd99a446 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -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)
diff --git a/ui/components/compliance/compliance-custom-details/threat-details.tsx b/ui/components/compliance/compliance-custom-details/threat-details.tsx
index 1cf127bf84..822612234d 100644
--- a/ui/components/compliance/compliance-custom-details/threat-details.tsx
+++ b/ui/components/compliance/compliance-custom-details/threat-details.tsx
@@ -54,6 +54,25 @@ export const ThreatCustomDetails = ({
conditional={true}
/>
)}
+
+ {typeof requirement.passedFindings === "number" &&
+ typeof requirement.totalFindings === "number" && (
+ <>
+
+ {requirement.totalFindings > 0 && (
+
+ )}
+ >
+ )}
{requirement.additionalInformation && (
diff --git a/ui/lib/compliance/threat.tsx b/ui/lib/compliance/threat.tsx
index 9aae82f7b8..a090087865 100644
--- a/ui/lib/compliance/threat.tsx
+++ b/ui/lib/compliance/threat.tsx
@@ -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)
diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts
index 733740749e..17e0189732 100644
--- a/ui/types/compliance.ts
+++ b/ui/types/compliance.ts
@@ -189,6 +189,9 @@ export interface RequirementItemData {
version: string;
description: string;
status: RequirementStatus;
+ // For Threat compliance:
+ passed_findings?: number;
+ total_findings?: number;
};
}