from unittest.mock import MagicMock, patch import pytest from prowler.providers.gcp.services.logging.logging_service import Logging from tests.providers.gcp.gcp_fixtures import ( GCP_PROJECT_ID, mock_api_client, mock_is_api_active, set_mocked_gcp_provider, ) class TestLoggingService: def test_service(self): with ( patch( "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", new=mock_is_api_active, ), patch( "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", new=mock_api_client, ), ): logging_client = Logging( set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) ) assert logging_client.service == "logging" assert logging_client.project_ids == [GCP_PROJECT_ID] assert len(logging_client.sinks) == 2 assert logging_client.sinks[0].name == "sink1" assert ( logging_client.sinks[0].destination == "storage.googleapis.com/example-bucket" ) assert logging_client.sinks[0].filter == "all" assert logging_client.sinks[0].project_id == GCP_PROJECT_ID assert logging_client.sinks[1].name == "sink2" assert ( logging_client.sinks[1].destination == f"bigquery.googleapis.com/projects/{GCP_PROJECT_ID}/datasets/example_dataset" ) assert logging_client.sinks[1].filter == "all" assert logging_client.sinks[1].project_id == GCP_PROJECT_ID assert len(logging_client.metrics) == 2 assert logging_client.metrics[0].name == "metric1" assert ( logging_client.metrics[0].type == "custom.googleapis.com/invoice/paid/amount" ) assert ( logging_client.metrics[0].filter == "resource.type=gae_app AND severity>=ERROR" ) assert logging_client.metrics[0].project_id == GCP_PROJECT_ID assert logging_client.metrics[1].name == "metric2" assert ( logging_client.metrics[1].type == "external.googleapis.com/prometheus/up" ) assert ( logging_client.metrics[1].filter == "resource.type=gae_app AND severity>=ERROR" ) assert logging_client.metrics[1].project_id == GCP_PROJECT_ID def test_org_sinks_fetched_when_project_has_organization(self): """_get_org_sinks() appends org-level sinks when projects have an org.""" from prowler.providers.gcp.models import GCPOrganization, GCPProject org_id = "999888777" provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) provider.projects = { GCP_PROJECT_ID: GCPProject( id=GCP_PROJECT_ID, number="123456789012", name="test", labels={}, lifecycle_state="ACTIVE", organization=GCPOrganization(id=org_id, name=f"organizations/{org_id}"), ) } mock_client = MagicMock() mock_client.sinks().list().execute.return_value = { "sinks": [ { "name": "org-sink", "destination": "storage.googleapis.com/org-bucket", "filter": "all", "includeChildren": True, } ] } mock_client.sinks().list_next.return_value = None mock_client.projects().metrics().list().execute.return_value = {"metrics": []} mock_client.projects().metrics().list_next.return_value = None with ( patch( "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", new=mock_is_api_active, ), patch( "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", return_value=mock_client, ), ): logging_svc = Logging(provider) org_sinks = [ s for s in logging_svc.sinks if s.project_id == f"organizations/{org_id}" ] assert len(org_sinks) == 1 assert org_sinks[0].name == "org-sink" assert org_sinks[0].include_children is True assert org_sinks[0].filter == "all" def test_org_sinks_skipped_when_no_organization(self): """_get_org_sinks() adds nothing when projects have no organization.""" with ( patch( "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", new=mock_is_api_active, ), patch( "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", new=mock_api_client, ), ): logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) org_sinks = [ s for s in logging_svc.sinks if s.project_id.startswith("organizations/") ] assert org_sinks == [] def test_get_metrics_populates_bucket_name(self): """_get_metrics() captures a metric's bucketName (for aggregated-sink crediting).""" bucket = "projects/central-logging-project/locations/eu/buckets/central-bucket" mock_client = MagicMock() mock_client.sinks().list().execute.return_value = {"sinks": []} mock_client.sinks().list_next.return_value = None mock_client.projects().metrics().list().execute.return_value = { "metrics": [ { "name": "central-metric", "metricDescriptor": { "type": "logging.googleapis.com/user/central-metric" }, "filter": "severity>=ERROR", "bucketName": bucket, } ] } mock_client.projects().metrics().list_next.return_value = None with ( patch( "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", new=mock_is_api_active, ), patch( "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", return_value=mock_client, ), ): logging_svc = Logging(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])) metrics = [m for m in logging_svc.metrics if m.name == "central-metric"] assert len(metrics) == 1 assert metrics[0].bucket_name == bucket class TestGetProjectsCoveredByAggregatedMetric: """Unit tests for the aggregated-sink crediting helper: one positive case and the guards that must NOT credit a project (so the metric-filter checks never false-pass). """ FILTER = 'protoPayload.methodName="SetIamPolicy"' ORG = "111222333" BUCKET = "projects/central-logging-project/locations/eu/buckets/central-bucket" def _clients( self, *, include_children=True, bucket_name=None, sink_destination=None, sink_filter="all", with_alert=True, project_org_id=None, ): from prowler.providers.gcp.models import GCPOrganization, GCPProject from prowler.providers.gcp.services.logging.logging_service import Metric, Sink from prowler.providers.gcp.services.monitoring.monitoring_service import ( AlertPolicy, ) bucket_name = self.BUCKET if bucket_name is None else bucket_name sink_destination = ( f"logging.googleapis.com/{self.BUCKET}" if sink_destination is None else sink_destination ) project_org_id = self.ORG if project_org_id is None else project_org_id logging_client = MagicMock() logging_client.project_ids = [GCP_PROJECT_ID] logging_client.projects = { GCP_PROJECT_ID: GCPProject( id=GCP_PROJECT_ID, number="123456789012", name="child", labels={}, lifecycle_state="ACTIVE", organization=GCPOrganization( id=project_org_id, name=f"organizations/{project_org_id}" ), ) } logging_client.metrics = [ Metric( name="central-metric", type="logging.googleapis.com/user/central-metric", filter=self.FILTER, project_id="central-logging-project", bucket_name=bucket_name, ) ] logging_client.sinks = [ Sink( name="org-sink", destination=sink_destination, filter=sink_filter, project_id=f"organizations/{self.ORG}", include_children=include_children, ) ] monitoring_client = MagicMock() monitoring_client.alert_policies = ( [ AlertPolicy( name="projects/central-logging-project/alertPolicies/ap", display_name="central-alert", enabled=True, filters=[ 'metric.type = "logging.googleapis.com/user/central-metric"' ], project_id="central-logging-project", ) ] if with_alert else [] ) return logging_client, monitoring_client def _run(self, logging_client, monitoring_client): from prowler.providers.gcp.services.logging.logging_service import ( get_projects_covered_by_aggregated_metric, ) return get_projects_covered_by_aggregated_metric( logging_client, monitoring_client, self.FILTER ) def test_covered_when_all_conditions_met(self): logging_client, monitoring_client = self._clients() assert self._run(logging_client, monitoring_client) == { GCP_PROJECT_ID: "central-metric" } def test_not_covered_without_alert(self): logging_client, monitoring_client = self._clients(with_alert=False) assert self._run(logging_client, monitoring_client) == {} def test_not_covered_when_metric_not_bucket_scoped(self): logging_client, monitoring_client = self._clients(bucket_name="") assert self._run(logging_client, monitoring_client) == {} def test_not_covered_when_sink_not_include_children(self): logging_client, monitoring_client = self._clients(include_children=False) assert self._run(logging_client, monitoring_client) == {} def test_not_covered_when_sink_filter_is_restrictive(self): logging_client, monitoring_client = self._clients( sink_filter='resource.type="gce_instance"' ) assert self._run(logging_client, monitoring_client) == {} def test_not_covered_when_sink_filter_omits_activity_stream(self): """A sink that routes cloudaudit streams but NOT Admin Activity (here, data_access only) does not deliver the entries the CIS metric filters match, so it must not be credited — right service, wrong stream.""" logging_client, monitoring_client = self._clients( sink_filter="logName: /logs/cloudaudit.googleapis.com%2Fdata_access" ) assert self._run(logging_client, monitoring_client) == {} def test_covered_when_sink_filter_carries_activity_stream_encoded(self): """A sink filtered to the cloudaudit streams (URL-encoded logName form, as returned by the Logging API) delivers every Admin Activity entry the CIS metric filters can match, so it must be credited.""" logging_client, monitoring_client = self._clients( sink_filter=( "logName: /logs/cloudaudit.googleapis.com%2Factivity OR " "logName: /logs/cloudaudit.googleapis.com%2Fdata_access" ) ) assert self._run(logging_client, monitoring_client) == { GCP_PROJECT_ID: "central-metric" } def test_covered_when_sink_filter_carries_activity_stream_plain(self): logging_client, monitoring_client = self._clients( sink_filter='logName="projects/p/logs/cloudaudit.googleapis.com/activity"' ) assert self._run(logging_client, monitoring_client) == { GCP_PROJECT_ID: "central-metric" } @pytest.mark.parametrize( "sink_filter", [ # --- Negation: the stream is named but excluded. --- 'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', '-logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', 'NOT log_id("cloudaudit.googleapis.com/activity")', # "!=" inequality (and its spaced form) excludes the stream. 'logName!="projects/p/logs/cloudaudit.googleapis.com%2Factivity"', 'logName != "projects/p/logs/cloudaudit.googleapis.com/activity"', # Activity negated inside a compound filter. 'resource.type="gce_instance" AND ' 'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', # --- Restriction: the stream is named but AND-narrowed, so only a # subset of Admin Activity entries reaches the bucket. --- 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' 'AND resource.type="gce_instance"', 'log_id("cloudaudit.googleapis.com/activity") ' 'AND resource.type="gce_instance"', 'logName="projects/p/logs/cloudaudit.googleapis.com/activity" ' "AND severity>=ERROR", 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' 'AND protoPayload.methodName="SetIamPolicy"', # --- OR-ed with a non-audit predicate: fail closed, since we credit # only unions of provable Cloud Audit stream selectors. --- 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' "OR severity>=ERROR", ], ) def test_not_covered_when_sink_filter_negated_or_restrictive(self, sink_filter): """A filter that names the Admin Activity stream but negates, narrows, or mixes in an unprovable predicate is not credited — we credit only filters we can prove deliver every Admin Activity entry the CIS metrics match.""" logging_client, monitoring_client = self._clients(sink_filter=sink_filter) assert self._run(logging_client, monitoring_client) == {} def test_covered_when_activity_logname_has_hyphenated_path(self): """A hyphen in the project path must not be mistaken for the ``-`` (NOT) negation operator — the activity stream is still delivered.""" logging_client, monitoring_client = self._clients( sink_filter='logName="projects/my-project/logs/cloudaudit.googleapis.com/activity"' ) assert self._run(logging_client, monitoring_client) == { GCP_PROJECT_ID: "central-metric" } def test_covered_when_sink_filter_uses_log_id_selector(self): """The ``log_id()`` form is an equivalent positive full-coverage selector of the Admin Activity stream and is credited like the ``logName`` form.""" logging_client, monitoring_client = self._clients( sink_filter='log_id("cloudaudit.googleapis.com/activity")' ) assert self._run(logging_client, monitoring_client) == { GCP_PROJECT_ID: "central-metric" } def test_not_covered_when_sink_destination_bucket_differs(self): logging_client, monitoring_client = self._clients( sink_destination="logging.googleapis.com/projects/x/locations/eu/buckets/other" ) assert self._run(logging_client, monitoring_client) == {} def test_not_covered_when_project_org_differs(self): logging_client, monitoring_client = self._clients(project_org_id="999999999") assert self._run(logging_client, monitoring_client) == {}