diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 8212574d30..fc9e1f4d45 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -11,10 +11,15 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398)
- `sdk_only` provider property (default `true`) and `Provider.get_app_providers()`, so a provider (built-in or external) stays CLI/SDK-only and hidden from the app unless it declares `sdk_only = False` [(#11427)](https://github.com/prowler-cloud/prowler/pull/11427)
+- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
### 🐞 Fixed
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
+- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
+- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
+- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
+- AWS AI Security Framework now renders in the dashboard instead of showing "No data found for this compliance", by adding the missing compliance view module [(#11470)](https://github.com/prowler-cloud/prowler/pull/11470)
---
diff --git a/prowler/__main__.py b/prowler/__main__.py
index f2637395c3..6cbaf575d9 100644
--- a/prowler/__main__.py
+++ b/prowler/__main__.py
@@ -438,7 +438,18 @@ def prowler():
)
else:
# Dynamic fallback: any external/custom provider
- output_options = global_provider.get_output_options(args, bulk_checks_metadata)
+ try:
+ output_options = global_provider.get_output_options(
+ args, bulk_checks_metadata
+ )
+ except NotImplementedError:
+ # No provider-specific OutputOptions: use the generic default so the
+ # run still produces output instead of aborting.
+ from prowler.providers.common.models import default_output_options
+
+ output_options = default_output_options(
+ global_provider, args, bulk_checks_metadata
+ )
# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
diff --git a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json
index a64a421c8a..cea7ad1655 100644
--- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json
+++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json
@@ -1863,7 +1863,9 @@
"Id": "ELB.4",
"Name": "Application load balancers should be configured to drop HTTP headers",
"Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.",
- "Checks": [],
+ "Checks": [
+ "elbv2_alb_drop_invalid_header_fields_enabled"
+ ],
"Attributes": [
{
"ItemId": "ELB.4",
diff --git a/prowler/config/config.py b/prowler/config/config.py
index cae0aee85f..e052202913 100644
--- a/prowler/config/config.py
+++ b/prowler/config/config.py
@@ -151,8 +151,8 @@ def get_available_compliance_frameworks(provider=None):
continue
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
- # External compliance via entry points; a provider may be served by
- # several packages, so iterate every directory it contributes.
+ # External per-provider compliance via entry points; a provider may be
+ # served by several packages, so iterate every directory it contributes.
ep_dirs = _get_ep_compliance_dirs()
for prov, paths in ep_dirs.items():
if provider and prov != provider:
@@ -165,6 +165,32 @@ def get_available_compliance_frameworks(provider=None):
name = file.name.removesuffix(".json")
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
+ # External multi-provider frameworks via the dedicated universal group;
+ # filtered by supports_provider when a provider is given.
+ for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
+ try:
+ module = ep.load()
+ path = (
+ module.__path__[0]
+ if hasattr(module, "__path__")
+ else os.path.dirname(module.__file__)
+ )
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+ continue
+ if not os.path.isdir(path):
+ continue
+ for file in os.scandir(path):
+ if file.is_file() and file.name.endswith(".json"):
+ name = file.name.removesuffix(".json")
+ if provider:
+ framework = load_compliance_framework_universal(file.path)
+ if framework is None or not framework.supports_provider(provider):
+ continue
+ if name not in available_compliance_frameworks:
+ available_compliance_frameworks.append(name)
return available_compliance_frameworks
diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py
index 8cc588cc4c..3610893008 100644
--- a/prowler/lib/check/compliance_models.py
+++ b/prowler/lib/check/compliance_models.py
@@ -478,9 +478,15 @@ class Compliance(BaseModel):
compliance_framework_name
not in bulk_compliance_frameworks
):
- bulk_compliance_frameworks[
- compliance_framework_name
- ] = load_compliance_framework(file_path)
+ # External JSON: tolerate non-legacy
+ # schemas (skip + warn) instead of aborting.
+ framework = load_compliance_framework(
+ file_path, fatal=False
+ )
+ if framework is not None:
+ bulk_compliance_frameworks[
+ compliance_framework_name
+ ] = framework
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -494,18 +500,26 @@ class Compliance(BaseModel):
# Testing Pending
def load_compliance_framework(
- compliance_specification_file: str,
-) -> Compliance:
- """load_compliance_framework loads and parse a Compliance Framework Specification"""
+ compliance_specification_file: str, fatal: bool = True
+) -> Optional[Compliance]:
+ """load_compliance_framework loads and parse a Compliance Framework Specification.
+
+ With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with
+ ``fatal=False`` (external JSONs) it is skipped with a warning and ``None``
+ is returned.
+ """
try:
- compliance_framework = Compliance.parse_file(compliance_specification_file)
+ return Compliance.parse_file(compliance_specification_file)
except ValidationError as error:
- logger.critical(
- f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
+ if fatal:
+ logger.critical(
+ f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
+ )
+ sys.exit(1)
+ logger.warning(
+ f"Skipping invalid compliance framework {compliance_specification_file}: {error}"
)
- sys.exit(1)
- else:
- return compliance_framework
+ return None
# ─── Universal Compliance Schema Models (Phase 1-3) ─────────────────────────
@@ -982,6 +996,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict:
if compliance_root and os.path.isdir(compliance_root):
_load_jsons_from_dir(compliance_root, provider, bulk)
+ # External multi-provider frameworks via the dedicated universal entry
+ # point group, kept separate from the per-provider `prowler.compliance`
+ # group so the legacy loader never parses a universal JSON. Built-ins
+ # (already in bulk) win on a name collision.
+ for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
+ try:
+ module = ep.load()
+ ep_dir = (
+ module.__path__[0]
+ if hasattr(module, "__path__")
+ else os.path.dirname(module.__file__)
+ )
+ if os.path.isdir(ep_dir):
+ _load_jsons_from_dir(ep_dir, provider, bulk)
+ except Exception as error:
+ logger.warning(
+ f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
except Exception as e:
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
return bulk
diff --git a/prowler/lib/outputs/compliance/generic/generic.py b/prowler/lib/outputs/compliance/generic/generic.py
index c7217db5b3..b774f09577 100644
--- a/prowler/lib/outputs/compliance/generic/generic.py
+++ b/prowler/lib/outputs/compliance/generic/generic.py
@@ -34,60 +34,48 @@ class GenericCompliance(ComplianceOutput):
Returns:
- None
"""
+
+ def compliance_row(requirement, attribute, finding=None):
+ # Read attribute fields defensively: GenericCompliance is the
+ # last-resort renderer for any framework, and provider-specific
+ # schemas (e.g. CIS, ENS, ISO27001) do not declare the universal
+ # Section/SubSection/SubGroup/Service/Type/Comment fields.
+ return GenericComplianceModel(
+ Provider=(finding.provider if finding else compliance.Provider.lower()),
+ Description=compliance.Description,
+ AccountId=finding.account_uid if finding else "",
+ Region=finding.region if finding else "",
+ AssessmentDate=str(timestamp),
+ Requirements_Id=requirement.Id,
+ Requirements_Description=requirement.Description,
+ Requirements_Attributes_Section=getattr(attribute, "Section", None),
+ Requirements_Attributes_SubSection=getattr(
+ attribute, "SubSection", None
+ ),
+ Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None),
+ Requirements_Attributes_Service=getattr(attribute, "Service", None),
+ Requirements_Attributes_Type=getattr(attribute, "Type", None),
+ Requirements_Attributes_Comment=getattr(attribute, "Comment", None),
+ Status=finding.status if finding else "MANUAL",
+ StatusExtended=(finding.status_extended if finding else "Manual check"),
+ ResourceId=finding.resource_uid if finding else "manual_check",
+ ResourceName=finding.resource_name if finding else "Manual check",
+ CheckId=finding.check_id if finding else "manual",
+ Muted=finding.muted if finding else False,
+ Framework=compliance.Framework,
+ Name=compliance.Name,
+ )
+
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
- compliance_row = GenericComplianceModel(
- Provider=finding.provider,
- Description=compliance.Description,
- AccountId=finding.account_uid,
- Region=finding.region,
- AssessmentDate=str(timestamp),
- Requirements_Id=requirement.Id,
- Requirements_Description=requirement.Description,
- Requirements_Attributes_Section=attribute.Section,
- Requirements_Attributes_SubSection=attribute.SubSection,
- Requirements_Attributes_SubGroup=attribute.SubGroup,
- Requirements_Attributes_Service=attribute.Service,
- Requirements_Attributes_Type=attribute.Type,
- Requirements_Attributes_Comment=attribute.Comment,
- Status=finding.status,
- StatusExtended=finding.status_extended,
- ResourceId=finding.resource_uid,
- ResourceName=finding.resource_name,
- CheckId=finding.check_id,
- Muted=finding.muted,
- Framework=compliance.Framework,
- Name=compliance.Name,
+ self._data.append(
+ compliance_row(requirement, attribute, finding)
)
- self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
- compliance_row = GenericComplianceModel(
- Provider=compliance.Provider.lower(),
- Description=compliance.Description,
- AccountId="",
- Region="",
- AssessmentDate=str(timestamp),
- Requirements_Id=requirement.Id,
- Requirements_Description=requirement.Description,
- Requirements_Attributes_Section=attribute.Section,
- Requirements_Attributes_SubSection=attribute.SubSection,
- Requirements_Attributes_SubGroup=attribute.SubGroup,
- Requirements_Attributes_Service=attribute.Service,
- Requirements_Attributes_Type=attribute.Type,
- Requirements_Attributes_Comment=attribute.Comment,
- Status="MANUAL",
- StatusExtended="Manual check",
- ResourceId="manual_check",
- ResourceName="Manual check",
- CheckId="manual",
- Muted=False,
- Framework=compliance.Framework,
- Name=compliance.Name,
- )
- self._data.append(compliance_row)
+ self._data.append(compliance_row(requirement, attribute))
diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py
index ed8f7faab0..f7f31666e1 100644
--- a/prowler/lib/outputs/jira/jira.py
+++ b/prowler/lib/outputs/jira/jira.py
@@ -229,7 +229,9 @@ class MarkdownToADFConverter:
return node
def _paragraph_with_text(self, text: str) -> Dict:
- return {"type": "paragraph", "content": [self._create_text_node(text, None)]}
+ # ADF forbids empty text nodes; emit an empty paragraph instead.
+ content = [self._create_text_node(text, None)] if text else []
+ return {"type": "paragraph", "content": content}
@staticmethod
def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None:
@@ -1118,6 +1120,18 @@ class Jira:
tenant_info: str = "",
) -> dict:
+ # ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT.
+ def _safe(value: str) -> str:
+ return value if (value and value.strip()) else "-"
+
+ check_id = _safe(check_id)
+ check_title = _safe(check_title)
+ status_extended = _safe(status_extended)
+ provider = _safe(provider)
+ region = _safe(region)
+ resource_uid = _safe(resource_uid)
+ resource_name = _safe(resource_name)
+
table_rows = [
{
"type": "tableRow",
diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json
new file mode 100644
index 0000000000..0293501578
--- /dev/null
+++ b/prowler/providers/aws/services/elbv2/elbv2_alb_drop_invalid_header_fields_enabled/elbv2_alb_drop_invalid_header_fields_enabled.metadata.json
@@ -0,0 +1,40 @@
+{
+ "Provider": "aws",
+ "CheckID": "elbv2_alb_drop_invalid_header_fields_enabled",
+ "CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
+ "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
+ "TTPs/Initial Access",
+ "Effects/Data Exposure"
+ ],
+ "ServiceName": "elbv2",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "medium",
+ "ResourceType": "AwsElbv2LoadBalancer",
+ "ResourceGroup": "network",
+ "Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.",
+ "Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields",
+ "https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn + No providers have been configured. Start by setting up a provider. +
+- No providers have been configured. Start by setting up a provider. -
-