mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(api): remove provider credentials for PDF report (#11845)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user