feat(api): remove provider credentials for PDF report (#11845)

This commit is contained in:
Pedro Martín
2026-07-07 09:45:34 +02:00
committed by GitHub
parent 4cb02a0ead
commit 3cc8f86780
8 changed files with 362 additions and 47 deletions
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.34.0] (Prowler UNRELEASED)
### 🚀 Added
- Compliance PDF reports no longer require provider credentials: findings are enriched from the provider metadata stored in the database, so reports generate even after the provider secret is deleted or its credentials become invalid [(#11845)](https://github.com/prowler-cloud/prowler/pull/11845)
---
## [1.33.1] (Prowler v5.32.1)
### 🐞 Fixed
+7 -19
View File
@@ -11,7 +11,6 @@ from uuid import UUID
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import rls_transaction
from api.models import Provider, Scan, ScanSummary, StateChoices, ThreatScoreSnapshot
from api.utils import initialize_prowler_provider
from celery.utils.log import get_task_logger
from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
from prowler.lib.check.compliance_models import (
@@ -27,6 +26,7 @@ from tasks.jobs.reports import (
ENSReportGenerator,
NIS2ReportGenerator,
ThreatScoreReportGenerator,
build_provider_metadata,
)
from tasks.jobs.threatscore import compute_threatscore_metrics
from tasks.jobs.threatscore_utils import (
@@ -841,24 +841,12 @@ def generate_compliance_reports(
tenant_id, scan_id
)
# Initialize the Prowler provider once for the whole report batch. Each
# generator used to re-init this in _load_compliance_data, paying the
# boto3/Azure-SDK construction cost 5 times per scan. The instance is
# only used by FindingOutput.transform_api_finding to enrich findings,
# so a single shared instance is correct.
logger.info("Initializing prowler_provider once for all reports (scan %s)", scan_id)
try:
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
prowler_provider = initialize_prowler_provider(provider_obj)
except Exception as init_error:
# If init fails the generators will fall back to lazy init in
# _load_compliance_data; we just log and continue.
logger.warning(
"Could not pre-initialize prowler_provider for scan %s: %s",
scan_id,
init_error,
)
prowler_provider = None
# Build a credential-free provider metadata stub once for the whole
# report batch. FindingOutput.transform_api_finding only reads static
# attributes (type plus a few identity fields), so reports never decrypt
# the ProviderSecret nor construct a cloud SDK session — generation keeps
# working after credentials are deleted or invalidated (PROWLER-2145).
prowler_provider = build_provider_metadata(provider_obj)
# Create shared findings cache up front so the eviction closure below
# can reference it. Defined BEFORE the closure to avoid the UnboundLocalError
@@ -98,6 +98,7 @@ from .config import (
from .csa import CSAReportGenerator
from .ens import ENSReportGenerator
from .nis2 import NIS2ReportGenerator
from .provider_metadata import build_provider_metadata
from .threatscore import ThreatScoreReportGenerator
__all__ = [
@@ -105,6 +106,7 @@ __all__ = [
"BaseComplianceReportGenerator",
"ComplianceData",
"RequirementData",
"build_provider_metadata",
"create_pdf_styles",
"get_requirement_metadata",
# Framework-specific generators
+11 -10
View File
@@ -11,7 +11,6 @@ from typing import Any
from api.db_router import READ_REPLICA_ALIAS
from api.db_utils import rls_transaction
from api.models import Provider, StatusChoices
from api.utils import initialize_prowler_provider
from celery.utils.log import get_task_logger
from prowler.lib.check.compliance_models import (
Compliance,
@@ -52,6 +51,7 @@ from .config import (
PADDING_SMALL,
FrameworkConfig,
)
from .provider_metadata import build_provider_metadata
logger = get_task_logger(__name__)
@@ -178,7 +178,8 @@ class ComplianceData:
attributes_by_requirement_id: Mapping of requirement IDs to their attributes
findings_by_check_id: Mapping of check IDs to their findings
provider_obj: Provider model object
prowler_provider: Initialized Prowler provider
prowler_provider: Credential-free provider metadata stub (see
``build_provider_metadata``)
"""
tenant_id: str
@@ -439,10 +440,10 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Optional pre-fetched Provider object
requirement_statistics: Optional pre-aggregated statistics
findings_cache: Optional pre-loaded findings cache
prowler_provider: Optional pre-initialized Prowler provider. When
generating multiple reports for the same scan the master
function initializes this once and passes it in to avoid
re-running boto3/Azure-SDK setup per framework.
prowler_provider: Optional provider metadata stub (see
``build_provider_metadata``). When generating multiple
reports for the same scan the master function builds it
once and passes it in.
**kwargs: Additional framework-specific arguments
"""
framework = self.config.display_name
@@ -896,9 +897,9 @@ class BaseComplianceReportGenerator(ABC):
provider_obj: Optional pre-fetched Provider
requirement_statistics: Optional pre-aggregated statistics
findings_cache: Optional pre-loaded findings
prowler_provider: Optional pre-initialized Prowler provider. When
the master function initializes it once and passes it in,
we skip the per-report ``initialize_prowler_provider`` call.
prowler_provider: Optional provider metadata stub. When the
master function builds it once and passes it in, we skip
the per-report ``build_provider_metadata`` call.
Returns:
Aggregated ComplianceData object
@@ -909,7 +910,7 @@ class BaseComplianceReportGenerator(ABC):
provider_obj = Provider.objects.get(id=provider_id)
if prowler_provider is None:
prowler_provider = initialize_prowler_provider(provider_obj)
prowler_provider = build_provider_metadata(provider_obj)
provider_type = provider_obj.provider
# Load compliance framework — fall back to the universal loader
@@ -0,0 +1,124 @@
from types import SimpleNamespace
from prowler.providers.github.models import GithubIdentityInfo
def build_provider_metadata(provider) -> SimpleNamespace:
"""Build a credential-free stand-in for the Prowler SDK provider.
``FindingOutput.transform_api_finding`` only reads static attributes
from the provider (``type`` plus a few identity/metadata fields used to
label accounts), so compliance reports never need the decrypted
``ProviderSecret`` nor a live cloud SDK session. This builds an object
exposing exactly those attributes from the ``Provider`` DB row, which
keeps report generation working when the provider secret has been
deleted or its credentials are no longer valid (PROWLER-2145).
Args:
provider: The API ``Provider`` model instance (only ``provider``,
``uid`` and ``alias`` are read).
Returns:
A ``SimpleNamespace`` mimicking the SDK provider attributes consumed
by ``FindingOutput.transform_api_finding`` / ``generate_output``.
"""
provider_type = provider.provider
uid = provider.uid
display_name = provider.alias or uid
# Defaults cover every attribute read unconditionally in
# FindingOutput.generate_output (``provider.auth_method`` is accessed
# directly for several provider types); identity lookups go through
# get_nested_attribute/getattr, which tolerate missing attributes.
stub = SimpleNamespace(
type=provider_type,
auth_method="",
identity=SimpleNamespace(),
)
if provider_type == "aws":
stub.identity = SimpleNamespace(account=uid)
elif provider_type == "azure":
stub.identity = SimpleNamespace(
identity_type="",
identity_id="",
tenant_ids=[""],
tenant_domain="",
subscriptions={uid: display_name},
)
elif provider_type == "gcp":
stub.identity = SimpleNamespace(profile="")
stub.projects = {
uid: SimpleNamespace(
id=uid,
name=display_name,
labels={},
organization=None,
)
}
elif provider_type == "kubernetes":
stub.identity = SimpleNamespace(context=uid, cluster=uid)
elif provider_type == "m365":
stub.identity = SimpleNamespace(
identity_type="",
identity_id="",
tenant_domain=uid,
tenant_id="",
)
elif provider_type == "github":
# generate_output assigns account fields only inside
# isinstance(identity, Github*IdentityInfo) branches, so the stub
# must carry a real GithubIdentityInfo instance.
stub.identity = GithubIdentityInfo(
account_id=uid,
account_name=display_name,
account_url="",
)
elif provider_type == "mongodbatlas":
stub.identity = SimpleNamespace(
organization_id=uid,
organization_name=display_name,
)
elif provider_type == "iac":
stub.provider_uid = uid
elif provider_type == "oraclecloud":
stub.identity = SimpleNamespace(
tenancy_id=uid,
tenancy_name=display_name,
)
elif provider_type == "alibabacloud":
stub.identity = SimpleNamespace(
identity_arn="",
account_id=uid,
account_name=display_name,
)
elif provider_type == "cloudflare":
stub.identity = SimpleNamespace(
audited_accounts=[uid],
accounts=[],
)
elif provider_type == "openstack":
stub.identity = SimpleNamespace(
username="",
project_id=uid,
project_name=display_name,
)
elif provider_type == "googleworkspace":
stub.identity = SimpleNamespace(
delegated_user="",
customer_id=uid,
domain=display_name,
)
elif provider_type == "vercel":
stub.identity = SimpleNamespace(
team=None,
user_id=uid,
username=display_name,
)
elif provider_type == "okta":
stub.identity = SimpleNamespace(
org_domain=uid,
client_id="",
)
return stub
@@ -178,7 +178,9 @@ def _load_findings_for_requirement_checks(
tenant_id (str): The tenant ID for Row-Level Security context.
scan_id (str): The ID of the scan to retrieve findings for.
check_ids (list[str]): List of check IDs to load findings for.
prowler_provider: The initialized Prowler provider instance.
prowler_provider: Credential-free provider metadata stub (see
``tasks.jobs.reports.build_provider_metadata``) consumed by
``FindingOutput.transform_api_finding``.
findings_cache (dict, optional): Cache of already loaded findings.
If provided, checks are first looked up in cache before querying database.
total_counts_out (dict, optional): If provided, populated with
+14 -17
View File
@@ -1044,10 +1044,10 @@ class TestStaleCleanupProtectionHelpers:
class TestGenerateThreatscoreReportFunction:
"""Test suite for generate_threatscore_report function."""
@patch("tasks.jobs.reports.base.initialize_prowler_provider")
@patch("tasks.jobs.reports.base.build_provider_metadata")
def test_generate_threatscore_report_exception_handling(
self,
mock_initialize_provider,
mock_build_provider_metadata,
tenants_fixture,
scans_fixture,
providers_fixture,
@@ -1057,7 +1057,7 @@ class TestGenerateThreatscoreReportFunction:
scan = scans_fixture[0]
provider = providers_fixture[0]
mock_initialize_provider.side_effect = Exception("Test exception")
mock_build_provider_metadata.side_effect = Exception("Test exception")
with pytest.raises(Exception) as exc_info:
generate_threatscore_report(
@@ -1167,7 +1167,6 @@ class TestGenerateComplianceReportsOptimized:
assert result["cis"] == {"upload": False, "path": ""}
mock_cis.assert_not_called()
@patch("api.utils.initialize_prowler_provider")
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@@ -1194,7 +1193,6 @@ class TestGenerateComplianceReportsOptimized:
mock_cis,
mock_upload_to_s3,
mock_rmtree,
mock_init_provider,
):
"""After each framework finishes, exclusive entries are evicted.
@@ -1223,7 +1221,6 @@ class TestGenerateComplianceReportsOptimized:
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out"
mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf"
mock_init_provider.return_value = Mock(name="prowler_provider")
# Seed the cache as if both frameworks had already loaded their
# findings. We mutate it indirectly: each generator wrapper is a
@@ -1266,7 +1263,7 @@ class TestGenerateComplianceReportsOptimized:
"shared must remain in cache because ENS still needs it"
)
@patch("tasks.jobs.report.initialize_prowler_provider")
@patch("tasks.jobs.report.build_provider_metadata")
@patch("tasks.jobs.report.rmtree")
@patch("tasks.jobs.report._upload_to_s3")
@patch("tasks.jobs.report.generate_cis_report")
@@ -1279,7 +1276,7 @@ class TestGenerateComplianceReportsOptimized:
@patch("tasks.jobs.report.Compliance.get_bulk")
@patch("tasks.jobs.report.Provider.objects.get")
@patch("tasks.jobs.report.ScanSummary.objects.filter")
def test_prowler_provider_initialized_once(
def test_provider_metadata_built_once(
self,
mock_scan_summary_filter,
mock_provider_get,
@@ -1293,11 +1290,11 @@ class TestGenerateComplianceReportsOptimized:
mock_cis,
mock_upload_to_s3,
mock_rmtree,
mock_init_provider,
mock_build_metadata,
):
"""``initialize_prowler_provider`` must be called exactly once for
the whole batch (PROWLER-1733). Previously each generator re-init'd
the SDK provider in ``_load_compliance_data`` → 5 inits per scan.
"""``build_provider_metadata`` must be called exactly once for the
whole batch and its result shared across all 5 reports
(PROWLER-1733 / PROWLER-2145).
"""
mock_scan_summary_filter.return_value.exists.return_value = True
mock_provider_get.return_value = Mock(uid="provider-uid", provider="aws")
@@ -1306,7 +1303,7 @@ class TestGenerateComplianceReportsOptimized:
mock_aggregate_stats.return_value = {}
mock_generate_output_dir.return_value = "/tmp/tenant/scan/x/prowler-out"
mock_upload_to_s3.return_value = "s3://bucket/tenant/scan/x/report.pdf"
mock_init_provider.return_value = Mock(name="prowler_provider")
mock_build_metadata.return_value = Mock(name="prowler_provider")
generate_compliance_reports(
tenant_id=str(uuid.uuid4()),
@@ -1325,14 +1322,14 @@ class TestGenerateComplianceReportsOptimized:
mock_nis2.assert_called_once()
mock_csa.assert_called_once()
mock_cis.assert_called_once()
# …but the SDK provider was initialized only once.
assert mock_init_provider.call_count == 1, (
f"expected 1 init, got {mock_init_provider.call_count} "
# …but the provider metadata stub was built only once.
assert mock_build_metadata.call_count == 1, (
f"expected 1 build, got {mock_build_metadata.call_count} "
f"(prowler_provider must be shared across reports)"
)
# The shared instance must reach every wrapper as kwargs.
shared = mock_init_provider.return_value
shared = mock_build_metadata.return_value
for mock_wrapper in (
mock_threatscore,
mock_ens,
@@ -0,0 +1,193 @@
"""Tests for the credential-free provider metadata stub (PROWLER-2145).
Every provider object used here is a plain ``SimpleNamespace`` WITHOUT a
``secret`` attribute: any code path trying to read ``provider.secret`` (the
coupling these tests guard against) would raise ``AttributeError`` and fail
the test. No database is required.
"""
from types import SimpleNamespace
import pytest
from api.models import Provider
from prowler.lib.outputs.finding import Finding as FindingOutput
from prowler.providers.github.models import GithubIdentityInfo
from tasks.jobs.reports import build_provider_metadata
PROVIDER_UID = "provider-uid-123"
PROVIDER_ALIAS = "my-provider-alias"
def _provider_row(provider_type: str, alias: str | None = PROVIDER_ALIAS):
"""Mimic the Provider DB row attributes read by build_provider_metadata."""
return SimpleNamespace(provider=provider_type, uid=PROVIDER_UID, alias=alias)
class TestBuildProviderMetadata:
@pytest.mark.parametrize("provider_type", Provider.ProviderChoices.values)
def test_every_provider_type_gets_safe_defaults(self, provider_type):
stub = build_provider_metadata(_provider_row(provider_type))
assert stub.type == provider_type
assert isinstance(stub.auth_method, str)
assert hasattr(stub, "identity")
def test_aws_identity_account_is_uid(self):
stub = build_provider_metadata(_provider_row("aws"))
assert stub.identity.account == PROVIDER_UID
def test_azure_identity_covers_generate_output_accesses(self):
stub = build_provider_metadata(_provider_row("azure"))
# generate_output indexes tenant_ids[0] and reads these directly.
assert stub.identity.tenant_ids
assert stub.identity.identity_type == ""
assert stub.identity.identity_id == ""
assert stub.identity.subscriptions == {PROVIDER_UID: PROVIDER_ALIAS}
def test_gcp_projects_keyed_by_uid(self):
stub = build_provider_metadata(_provider_row("gcp"))
project = stub.projects[PROVIDER_UID]
assert project.id == PROVIDER_UID
assert project.name == PROVIDER_ALIAS
assert project.labels == {}
# generate_output calls getattr(project, "organization") without a
# default, so the attribute must exist (None skips the org branch).
assert project.organization is None
def test_kubernetes_identity_context_and_cluster(self):
stub = build_provider_metadata(_provider_row("kubernetes"))
assert stub.identity.context == PROVIDER_UID
assert stub.identity.cluster == PROVIDER_UID
def test_github_identity_is_real_identity_info(self):
# generate_output only assigns account fields inside
# isinstance(identity, Github*IdentityInfo) branches.
stub = build_provider_metadata(_provider_row("github"))
assert isinstance(stub.identity, GithubIdentityInfo)
assert stub.identity.account_id == PROVIDER_UID
assert stub.identity.account_name == PROVIDER_ALIAS
def test_iac_provider_uid(self):
stub = build_provider_metadata(_provider_row("iac"))
assert stub.provider_uid == PROVIDER_UID
def test_alias_falls_back_to_uid(self):
stub = build_provider_metadata(_provider_row("azure", alias=None))
assert stub.identity.subscriptions == {PROVIDER_UID: PROVIDER_UID}
def _check_metadata_dict(provider_type: str, check_id: str) -> dict:
return {
"provider": provider_type,
"checkid": check_id,
"checktitle": "Test check title",
"checktype": [],
# CheckMetadata validates ServiceName == check_id.split("_")[0]
"servicename": check_id.split("_")[0],
"subservicename": "",
"severity": "high",
"resourcetype": "resource-type",
"description": "",
"risk": "",
"relatedurl": "",
"remediation": {
"recommendation": {"text": "", "url": ""},
"code": {"nativeiac": "", "terraform": "", "cli": "", "other": ""},
},
"resourceidtemplate": "",
"categories": [],
"dependson": [],
"relatedto": [],
"notes": "",
}
class _FakeFinding:
"""Attribute-faithful Finding stand-in.
A plain object instead of ``Mock``: only the attributes the Django model
exposes exist, so any new provider-attribute read in generate_output
(e.g. cloudflare's ``getattr(finding, "account_id", ...)``) hits the
same missing-attribute path it would hit in production instead of being
masked by Mock auto-created attributes.
"""
def _finding_model(provider_type: str, check_id: str, region: str):
"""Mimic the Django Finding row attributes read by transform_api_finding."""
resource = SimpleNamespace(
uid="resource-uid",
name="resource-name",
metadata="{}",
details="",
region=region,
tags=SimpleNamespace(all=lambda: []),
)
finding = _FakeFinding()
finding.resources = SimpleNamespace(first=lambda: resource)
finding.check_metadata = _check_metadata_dict(provider_type, check_id)
finding.status = "FAIL"
finding.status_extended = "failed for testing"
finding.muted = False
return finding
_FINDING_REGION = "region-x"
# Expected (account_uid, region) of the transformed finding per provider
# type, with resource.region = _FINDING_REGION. Keyed by every
# Provider.ProviderChoices value so that adding a new provider type without
# extending build_provider_metadata (and this table) fails the test below
# instead of breaking PDF generation at runtime.
_EXPECTED_TRANSFORM = {
"aws": (PROVIDER_UID, _FINDING_REGION),
"azure": (PROVIDER_UID, _FINDING_REGION),
"gcp": (PROVIDER_UID, _FINDING_REGION),
# transform_api_finding strips the "namespace: " prefix and
# generate_output re-adds it.
"kubernetes": (PROVIDER_UID, f"namespace: {_FINDING_REGION}"),
"m365": (PROVIDER_UID, _FINDING_REGION),
# For GitHub the owner comes from resource.region.
"github": (_FINDING_REGION, _FINDING_REGION),
"mongodbatlas": (PROVIDER_UID, _FINDING_REGION),
"iac": (PROVIDER_UID, _FINDING_REGION),
"oraclecloud": (PROVIDER_UID, _FINDING_REGION),
"alibabacloud": (PROVIDER_UID, _FINDING_REGION),
# Cloudflare uses the zone name (falls back to resource.name) as region.
"cloudflare": (PROVIDER_UID, "resource-name"),
"openstack": (PROVIDER_UID, _FINDING_REGION),
"image": ("image", _FINDING_REGION),
"googleworkspace": (PROVIDER_UID, _FINDING_REGION),
"vercel": (PROVIDER_UID, "global"),
"okta": (PROVIDER_UID, "global"),
}
class TestTransformApiFindingWithMetadataStub:
"""transform_api_finding must work end-to-end with the stub — i.e.
without a credentialed SDK provider — for EVERY API provider type."""
@pytest.mark.parametrize("provider_type", Provider.ProviderChoices.values)
def test_transform_with_stub(self, provider_type):
assert provider_type in _EXPECTED_TRANSFORM, (
f"New provider type {provider_type!r}: add a branch to "
f"build_provider_metadata covering the attributes read by "
f"FindingOutput.generate_output, then add its expected "
f"(account_uid, region) here."
)
expected_account_uid, expected_region = _EXPECTED_TRANSFORM[provider_type]
stub = build_provider_metadata(_provider_row(provider_type))
check_id = f"{provider_type}_test_check"
finding_model = _finding_model(provider_type, check_id, _FINDING_REGION)
output = FindingOutput.transform_api_finding(finding_model, stub)
assert output.check_id == check_id
assert output.status == "FAIL"
assert output.account_uid == expected_account_uid
assert output.region == expected_region
assert output.resource_name
assert output.resource_uid