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; }; }