mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
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:
@@ -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)
|
||||
|
||||
+21
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -189,6 +189,9 @@ export interface RequirementItemData {
|
||||
version: string;
|
||||
description: string;
|
||||
status: RequirementStatus;
|
||||
// For Threat compliance:
|
||||
passed_findings?: number;
|
||||
total_findings?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user