From 3cc8f8678025a72f7b08e37e47b22f58423aa6f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 7 Jul 2026 09:45:34 +0200 Subject: [PATCH] feat(api): remove provider credentials for PDF report (#11845) --- api/CHANGELOG.md | 8 + api/src/backend/tasks/jobs/report.py | 26 +-- .../backend/tasks/jobs/reports/__init__.py | 2 + api/src/backend/tasks/jobs/reports/base.py | 21 +- .../tasks/jobs/reports/provider_metadata.py | 124 +++++++++++ .../backend/tasks/jobs/threatscore_utils.py | 4 +- api/src/backend/tasks/tests/test_reports.py | 31 ++- .../tests/test_reports_provider_metadata.py | 193 ++++++++++++++++++ 8 files changed, 362 insertions(+), 47 deletions(-) create mode 100644 api/src/backend/tasks/jobs/reports/provider_metadata.py create mode 100644 api/src/backend/tasks/tests/test_reports_provider_metadata.py diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index d50816143f..85387bf9d7 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -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 diff --git a/api/src/backend/tasks/jobs/report.py b/api/src/backend/tasks/jobs/report.py index b40516dadf..c9d63a63ac 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -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 diff --git a/api/src/backend/tasks/jobs/reports/__init__.py b/api/src/backend/tasks/jobs/reports/__init__.py index a538416f59..94da8d6524 100644 --- a/api/src/backend/tasks/jobs/reports/__init__.py +++ b/api/src/backend/tasks/jobs/reports/__init__.py @@ -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 diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py index f51319a846..574609280a 100644 --- a/api/src/backend/tasks/jobs/reports/base.py +++ b/api/src/backend/tasks/jobs/reports/base.py @@ -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 diff --git a/api/src/backend/tasks/jobs/reports/provider_metadata.py b/api/src/backend/tasks/jobs/reports/provider_metadata.py new file mode 100644 index 0000000000..632f15c424 --- /dev/null +++ b/api/src/backend/tasks/jobs/reports/provider_metadata.py @@ -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 diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py index 2e2fb87ba5..2f968749dd 100644 --- a/api/src/backend/tasks/jobs/threatscore_utils.py +++ b/api/src/backend/tasks/jobs/threatscore_utils.py @@ -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 diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py index c290e6fe1d..5d156f9d81 100644 --- a/api/src/backend/tasks/tests/test_reports.py +++ b/api/src/backend/tasks/tests/test_reports.py @@ -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, diff --git a/api/src/backend/tasks/tests/test_reports_provider_metadata.py b/api/src/backend/tasks/tests/test_reports_provider_metadata.py new file mode 100644 index 0000000000..6fe1c32255 --- /dev/null +++ b/api/src/backend/tasks/tests/test_reports_provider_metadata.py @@ -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