Compare commits

...

8 Commits

Author SHA1 Message Date
Prowler Bot 19a1ac2744 chore(changelog): prepare for v5.29.3 (#11506)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-09 08:18:24 +02:00
Prowler Bot 859fe1a29f fix(gcp): honour org-aggregated sinks in metric-filter checks (#11501)
Co-authored-by: Aline Almeida <aline@tuplita.ai>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-06-08 16:58:05 +02:00
Prowler Bot a0ee9107db fix(api): create Neo4j driver lazily so an outage can't block API startup (#11498)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-06-08 14:00:34 +02:00
Prowler Bot d49ec58c02 fix(ui): preserve active tab styling with tooltips (#11495)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-06-08 12:24:52 +02:00
Prowler Bot 736f3f6f02 fix(jira): avoid 400 INVALID_INPUT on findings with empty field (#11477)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-06-05 13:48:21 +02:00
Prowler Bot 7b8acf974a fix(gcp): pass iam_service_account_unused for disabled service accounts (#11473)
Co-authored-by: Aline Almeida <aline@tuplita.ai>
2026-06-05 12:12:54 +02:00
Prowler Bot c6d8aa78dc fix(gcp): honour org-level aggregated sinks in logging_sink_created check (#11462)
Co-authored-by: Oleksandr_Sanin <alexaaander.sanin@gmail.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-06-04 12:18:57 +02:00
Prowler Bot 7c9f8971a5 chore(release): Bump versions to v5.29.3 (#11459)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-03 20:45:37 +02:00
43 changed files with 2508 additions and 165 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.2
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.3
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.30.3] (Prowler v5.29.3)
### 🐞 Fixed
- API startup no longer crashes when Neo4j is unreachable, as the Neo4j driver now connects lazily on first use rather than during app initialization [(#11491)](https://github.com/prowler-cloud/prowler/pull/11491)
---
## [1.30.1] (Prowler v5.29.1)
### 🐞 Fixed
+1 -1
View File
@@ -68,7 +68,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.30.2"
version = "1.30.3"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+6 -34
View File
@@ -1,12 +1,14 @@
import logging
import os
import sys
from pathlib import Path
from django.apps import AppConfig
from django.conf import settings
from config.custom_logging import BackendLogger
from config.env import env
from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(BackendLogger.API)
@@ -30,7 +32,6 @@ class ApiConfig(AppConfig):
def ready(self):
from api import schema_extensions # noqa: F401
from api import signals # noqa: F401
from api.attack_paths import database as graph_database
# Generate required cryptographic keys if not present, but only if:
# `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app
@@ -41,37 +42,8 @@ class ApiConfig(AppConfig):
):
self._ensure_crypto_keys()
# Commands that don't need Neo4j
SKIP_NEO4J_DJANGO_COMMANDS = [
"makemigrations",
"migrate",
"pgpartition",
"check",
"help",
"showmigrations",
"check_and_fix_socialaccount_sites_migration",
]
# Skip eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook)
if getattr(settings, "TESTING", False) or (
len(sys.argv) > 1
and (
(
"manage.py" in sys.argv[0]
and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS
)
or "celery" in sys.argv[0]
)
):
logger.info(
"Skipping eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)"
)
else:
graph_database.init_driver()
# Neo4j driver is initialized at API startup (see api.attack_paths.database)
# It remains lazy for Celery workers and selected Django commands
# Neo4j driver is created lazily on first use (see api.attack_paths.database).
# App init never contacts Neo4j, so a Neo4j outage cannot block API startup.
def _ensure_crypto_keys(self):
"""
+18 -4
View File
@@ -1,22 +1,24 @@
import atexit
import logging
import threading
from contextlib import contextmanager
from typing import Any, Iterator
from uuid import UUID
import neo4j
import neo4j.exceptions
from config.env import env
from django.conf import settings
from api.attack_paths.retryable_session import RetryableSession
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
PROVIDER_RESOURCE_LABEL,
get_provider_label,
)
from api.attack_paths.retryable_session import RetryableSession
# Without this Celery goes crazy with Neo4j logging
logging.getLogger("neo4j").setLevel(logging.ERROR)
logging.getLogger("neo4j").propagate = False
@@ -28,6 +30,9 @@ READ_QUERY_TIMEOUT_SECONDS = env.int(
"ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
)
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
# Shorter than CONN_ACQUISITION_TIMEOUT — the driver requires acquisition to be
# the longer of the two (it may include opening a new connection).
CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5)
CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15)
READ_EXCEPTION_CODES = [
"Neo.ClientError.Statement.AccessMode",
@@ -58,15 +63,24 @@ def init_driver() -> neo4j.Driver:
uri = get_uri()
config = settings.DATABASES["neo4j"]
_driver = neo4j.GraphDatabase.driver(
driver = neo4j.GraphDatabase.driver(
uri,
auth=(config["USER"], config["PASSWORD"]),
keep_alive=True,
max_connection_lifetime=7200,
connection_timeout=CONNECTION_TIMEOUT,
connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT,
max_connection_pool_size=50,
)
_driver.verify_connectivity()
# Publish the singleton only after connectivity is verified so a
# failed probe does not leave an unverified driver behind. Close the
# driver on failure so a repeatedly-probed outage cannot leak pools.
try:
driver.verify_connectivity()
except Exception:
driver.close()
raise
_driver = driver
# Register cleanup handler (only runs once since we're inside the _driver is None block)
atexit.register(close_driver)
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.30.2
version: 1.30.3
description: |-
Prowler API specification.
+12 -44
View File
@@ -182,23 +182,19 @@ def _make_app():
return ApiConfig("api", api)
def test_ready_initializes_driver_for_api_process(monkeypatch):
@pytest.mark.parametrize(
"argv",
[
["gunicorn"],
["celery", "-A", "api"],
["manage.py", "migrate"],
],
ids=["api", "celery", "manage_py"],
)
def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv):
"""ready() must never contact Neo4j; the driver is created lazily on first use."""
config = _make_app()
_set_argv(monkeypatch, ["gunicorn"])
_set_testing(monkeypatch, False)
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_called_once()
def test_ready_skips_driver_for_celery(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, ["celery", "-A", "api"])
_set_argv(monkeypatch, argv)
_set_testing(monkeypatch, False)
with (
@@ -208,31 +204,3 @@ def test_ready_skips_driver_for_celery(monkeypatch):
config.ready()
init_driver.assert_not_called()
def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, ["manage.py", "migrate"])
_set_testing(monkeypatch, False)
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_not_called()
def test_ready_skips_driver_when_testing(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, ["gunicorn"])
_set_testing(monkeypatch, True)
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_not_called()
@@ -1,15 +1,16 @@
"""
Tests for Neo4j database lazy initialization.
The Neo4j driver connects on first use by default. API processes may
eagerly initialize the driver during app startup, while Celery workers
remain lazy. These tests validate the database module behavior itself.
The Neo4j driver is created on first use for every process type; app startup
never contacts Neo4j. These tests validate the database module behavior itself.
"""
import threading
from unittest.mock import MagicMock, patch
import neo4j
import neo4j.exceptions
import pytest
import api.attack_paths.database as db_module
@@ -59,6 +60,32 @@ class TestLazyInitialization:
assert result is mock_driver
assert db_module._driver is mock_driver
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_init_driver_leaves_driver_none_when_verify_fails(
self, mock_driver_factory, mock_settings
):
"""A failed verify_connectivity() must not publish or leak the driver."""
mock_driver = MagicMock()
mock_driver.verify_connectivity.side_effect = (
neo4j.exceptions.ServiceUnavailable("down")
)
mock_driver_factory.return_value = mock_driver
mock_settings.DATABASES = {
"neo4j": {
"HOST": "localhost",
"PORT": 7687,
"USER": "neo4j",
"PASSWORD": "password",
}
}
with pytest.raises(neo4j.exceptions.ServiceUnavailable):
db_module.init_driver()
assert db_module._driver is None
mock_driver.close.assert_called_once()
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_init_driver_returns_cached_driver_on_subsequent_calls(
@@ -116,21 +143,23 @@ class TestConnectionAcquisitionTimeout:
@pytest.fixture(autouse=True)
def reset_module_state(self):
original_driver = db_module._driver
original_timeout = db_module.CONN_ACQUISITION_TIMEOUT
original_acq_timeout = db_module.CONN_ACQUISITION_TIMEOUT
original_conn_timeout = db_module.CONNECTION_TIMEOUT
db_module._driver = None
yield
db_module._driver = original_driver
db_module.CONN_ACQUISITION_TIMEOUT = original_timeout
db_module.CONN_ACQUISITION_TIMEOUT = original_acq_timeout
db_module.CONNECTION_TIMEOUT = original_conn_timeout
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_driver_receives_configured_timeout(
self, mock_driver_factory, mock_settings
):
"""init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver."""
"""init_driver() should pass the configured timeouts to the neo4j driver."""
mock_driver_factory.return_value = MagicMock()
mock_settings.DATABASES = {
"neo4j": {
@@ -141,11 +170,13 @@ class TestConnectionAcquisitionTimeout:
}
}
db_module.CONN_ACQUISITION_TIMEOUT = 42
db_module.CONNECTION_TIMEOUT = 7
db_module.init_driver()
_, kwargs = mock_driver_factory.call_args
assert kwargs["connection_acquisition_timeout"] == 42
assert kwargs["connection_timeout"] == 7
class TestAtexitRegistration:
Generated
+1 -1
View File
@@ -4498,7 +4498,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.30.2"
version = "1.30.3"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
+11
View File
@@ -2,6 +2,17 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.29.3] (Prowler v5.29.3)
### 🐞 Fixed
- 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)
- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488)
- 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)
---
## [5.29.1] (Prowler v5.29.1)
### 🐞 Fixed
+1 -1
View File
@@ -48,7 +48,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.29.2"
prowler_version = "5.29.3"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+15 -1
View File
@@ -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",
@@ -37,6 +37,7 @@ class IAM(GCPService):
display_name=account.get("displayName", ""),
project_id=project_id,
uniqueId=account.get("uniqueId", ""),
disabled=account.get("disabled", False),
)
)
@@ -102,6 +103,7 @@ class ServiceAccount(BaseModel):
keys: list[Key] = []
project_id: str
uniqueId: str
disabled: bool = False
class AccessApproval(GCPService):
@@ -19,7 +19,12 @@ class iam_service_account_unused(Check):
resource_id=account.email,
location=iam_client.region,
)
if account.uniqueId in sa_ids_used:
if account.disabled:
report.status = "PASS"
report.status_extended = (
f"Service Account {account.email} is disabled and cannot be used."
)
elif account.uniqueId in sa_ids_used:
report.status = "PASS"
report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days."
else:
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
from prowler.providers.gcp.services.logging.logging_service import (
get_projects_covered_by_aggregated_metric,
)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -10,12 +13,10 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable
):
def execute(self) -> Check_Report_GCP:
findings = []
metric_filter = 'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*'
projects_with_metric = set()
for metric in logging_client.metrics:
if (
'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*'
in metric.filter
):
if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -33,6 +34,11 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable
break
findings.append(report)
# Credit projects whose logs are centrally monitored via an org-level
# aggregated sink to a bucket-scoped metric + alert (instead of failing them).
centrally_covered = get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
)
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -46,8 +52,12 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable
else "GCP Project"
),
)
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
if project in centrally_covered:
report.status = "PASS"
report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
else:
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
from prowler.providers.gcp.services.logging.logging_service import (
get_projects_covered_by_aggregated_metric,
)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
metric_filter = 'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"'
projects_with_metric = set()
for metric in logging_client.metrics:
if (
'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"'
in metric.filter
):
if metric_filter in metric.filter:
metric_name = getattr(metric, "name", None) or "unknown"
report = Check_Report_GCP(
metadata=self.metadata(),
@@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(
break
findings.append(report)
centrally_covered = get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
)
for project in logging_client.project_ids:
if project not in projects_with_metric:
project_obj = logging_client.projects.get(project)
@@ -46,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled(
location=logging_client.region,
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
)
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
if project in centrally_covered:
report.status = "PASS"
report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
else:
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
from prowler.providers.gcp.services.logging.logging_service import (
get_projects_covered_by_aggregated_metric,
)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab
):
def execute(self) -> Check_Report_GCP:
findings = []
metric_filter = 'protoPayload.serviceName="compute.googleapis.com"'
projects_with_metric = set()
for metric in logging_client.metrics:
if 'protoPayload.serviceName="compute.googleapis.com"' in metric.filter:
if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab
break
findings.append(report)
centrally_covered = get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
)
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab
else "GCP Project"
),
)
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}."
if project in centrally_covered:
report.status = "PASS"
report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
else:
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated for Compute Engine configuration changes in project {project}."
findings.append(report)
return findings
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
from prowler.providers.gcp.services.logging.logging_service import (
get_projects_covered_by_aggregated_metric,
)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
metric_filter = 'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")'
projects_with_metric = set()
for metric in logging_client.metrics:
if (
'resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")'
in metric.filter
):
if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check)
break
findings.append(report)
centrally_covered = get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
)
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check)
else "GCP Project"
),
)
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
if project in centrally_covered:
report.status = "PASS"
report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
else:
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
from prowler.providers.gcp.services.logging.logging_service import (
get_projects_covered_by_aggregated_metric,
)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
metric_filter = '(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")'
projects_with_metric = set()
for metric in logging_client.metrics:
if (
'(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")'
in metric.filter
):
if metric_filter in metric.filter:
metric_name = getattr(metric, "name", None) or "unknown"
report = Check_Report_GCP(
metadata=self.metadata(),
@@ -36,6 +37,9 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(
break
findings.append(report)
centrally_covered = get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
)
for project in logging_client.project_ids:
if project not in projects_with_metric:
project_obj = logging_client.projects.get(project)
@@ -47,8 +51,12 @@ class logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled(
location=logging_client.region,
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
)
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
if project in centrally_covered:
report.status = "PASS"
report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
else:
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
from prowler.providers.gcp.services.logging.logging_service import (
get_projects_covered_by_aggregated_metric,
)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -10,9 +13,10 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes
):
def execute(self) -> Check_Report_GCP:
findings = []
metric_filter = 'protoPayload.methodName="cloudsql.instances.update"'
projects_with_metric = set()
for metric in logging_client.metrics:
if 'protoPayload.methodName="cloudsql.instances.update"' in metric.filter:
if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -30,6 +34,9 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes
break
findings.append(report)
centrally_covered = get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
)
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -43,8 +50,12 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes
else "GCP Project"
),
)
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
if project in centrally_covered:
report.status = "PASS"
report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
else:
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
from prowler.providers.gcp.services.logging.logging_service import (
get_projects_covered_by_aggregated_metric,
)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
metric_filter = 'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")'
projects_with_metric = set()
for metric in logging_client.metrics:
if (
'resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")'
in metric.filter
):
if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(
break
findings.append(report)
centrally_covered = get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
)
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(
else "GCP Project"
),
)
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
if project in centrally_covered:
report.status = "PASS"
report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
else:
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
from prowler.providers.gcp.services.logging.logging_service import (
get_projects_covered_by_aggregated_metric,
)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
metric_filter = 'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")'
projects_with_metric = set()
for metric in logging_client.metrics:
if (
'resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")'
in metric.filter
):
if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check)
break
findings.append(report)
centrally_covered = get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
)
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check)
else "GCP Project"
),
)
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
if project in centrally_covered:
report.status = "PASS"
report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
else:
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
@@ -1,5 +1,8 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.logging.logging_client import logging_client
from prowler.providers.gcp.services.logging.logging_service import (
get_projects_covered_by_aggregated_metric,
)
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
@@ -8,12 +11,10 @@ from prowler.providers.gcp.services.monitoring.monitoring_client import (
class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(Check):
def execute(self) -> Check_Report_GCP:
findings = []
metric_filter = 'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")'
projects_with_metric = set()
for metric in logging_client.metrics:
if (
'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")'
in metric.filter
):
if metric_filter in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -31,6 +32,9 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(
break
findings.append(report)
centrally_covered = get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
)
for project in logging_client.project_ids:
if project not in projects_with_metric:
report = Check_Report_GCP(
@@ -44,8 +48,12 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(
else "GCP Project"
),
)
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
if project in centrally_covered:
report.status = "PASS"
report.status_extended = f"Log metric filter {centrally_covered[project]} found with an alert, covering project {project} via an organization-level aggregated sink."
else:
report.status = "FAIL"
report.status_extended = f"There are no log metric filters or alerts associated in project {project}."
findings.append(report)
return findings
@@ -12,6 +12,7 @@ class Logging(GCPService):
self.sinks = []
self.metrics = []
self._get_sinks()
self._get_org_sinks()
self._get_metrics()
def _get_sinks(self):
@@ -39,6 +40,38 @@ class Logging(GCPService):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_org_sinks(self):
"""Fetch org-level sinks with includeChildren so child projects are not falsely failed."""
org_ids = set()
for project in self.projects.values():
if project.organization:
org_ids.add(project.organization.id)
for org_id in org_ids:
try:
request = self.client.sinks().list(parent=f"organizations/{org_id}")
while request is not None:
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
for sink in response.get("sinks", []):
self.sinks.append(
Sink(
name=sink["name"],
destination=sink["destination"],
filter=sink.get("filter", "all"),
project_id=f"organizations/{org_id}",
include_children=sink.get("includeChildren", False),
)
)
request = self.client.sinks().list_next(
previous_request=request, previous_response=response
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_metrics(self):
for project_id in self.project_ids:
try:
@@ -57,6 +90,7 @@ class Logging(GCPService):
type=metric["metricDescriptor"]["type"],
filter=metric["filter"],
project_id=project_id,
bucket_name=metric.get("bucketName", ""),
)
)
@@ -76,6 +110,7 @@ class Sink(BaseModel):
destination: str
filter: str
project_id: str
include_children: bool = False
class Metric(BaseModel):
@@ -83,3 +118,59 @@ class Metric(BaseModel):
type: str
filter: str
project_id: str
bucket_name: str = ""
def get_projects_covered_by_aggregated_metric(
logging_client, monitoring_client, metric_filter
):
"""Return {project_id: metric_name} for scanned projects whose logs are routed,
via an organization-level sink with includeChildren=True, to a bucket that holds
a bucket-scoped log metric matching ``metric_filter`` that has an alert policy.
The CIS GCP logging-metric checks are written per-project, but a common (and
recommended) topology centralizes monitoring: an org-level aggregated sink ships
every child project's logs into one bucket, where a single bucket-scoped metric
+ alert covers them all. Without crediting that, those child projects are falsely
failed. Mirrors the org-sink handling already in ``logging_sink_created`` (#11355).
"""
# Buckets that hold a matching, alerted, bucket-scoped metric -> metric name.
bucket_to_metric = {}
for metric in logging_client.metrics:
if not getattr(metric, "bucket_name", ""):
continue
if metric_filter not in metric.filter:
continue
if any(
metric.name in policy_filter
for alert_policy in monitoring_client.alert_policies
for policy_filter in alert_policy.filters
):
bucket_to_metric[metric.bucket_name] = metric.name
if not bucket_to_metric:
return {}
# Org resources whose includeChildren sink targets one of those buckets.
org_to_metric = {}
for sink in logging_client.sinks:
if not getattr(sink, "include_children", False):
continue
if getattr(sink, "filter", "all") != "all":
continue
for bucket, metric_name in bucket_to_metric.items():
# sink.destination e.g. "logging.googleapis.com/projects/.../buckets/X";
# metric.bucket_name e.g. "projects/.../buckets/X".
if sink.destination.endswith(bucket):
org_to_metric[sink.project_id] = metric_name
break
if not org_to_metric:
return {}
# Scanned projects sitting under a covering organization.
covered = {}
for project_id in logging_client.project_ids:
project = logging_client.projects.get(project_id)
organization = getattr(project, "organization", None) if project else None
if organization and f"organizations/{organization.id}" in org_to_metric:
covered[project_id] = org_to_metric[f"organizations/{organization.id}"]
return covered
@@ -5,26 +5,30 @@ from prowler.providers.gcp.services.logging.logging_client import logging_client
class logging_sink_created(Check):
def execute(self) -> Check_Report_GCP:
findings = []
# Map project_id -> sink for direct project-level sinks
projects_with_logging_sink = {}
for sink in logging_client.sinks:
if sink.filter == "all":
if sink.filter == "all" and not sink.include_children:
projects_with_logging_sink[sink.project_id] = sink
# Collect org resource names that have a covering sink (includeChildren=True)
covering_org_sinks = {}
for sink in logging_client.sinks:
if sink.filter == "all" and sink.include_children:
covering_org_sinks[sink.project_id] = sink
for project in logging_client.project_ids:
if project not in projects_with_logging_sink.keys():
project_obj = logging_client.projects.get(project)
report = Check_Report_GCP(
metadata=self.metadata(),
resource=project_obj,
resource_id=project,
project_id=project,
location=logging_client.region,
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
)
report.status = "FAIL"
report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
findings.append(report)
else:
project_obj = logging_client.projects.get(project)
# Determine whether this project is covered by an org-level sink
org = getattr(project_obj, "organization", None) if project_obj else None
org_resource = f"organizations/{org.id}" if org else None
covering_sink = (
covering_org_sinks.get(org_resource) if org_resource else None
)
if project in projects_with_logging_sink:
sink = projects_with_logging_sink[project]
sink_name = getattr(sink, "name", None) or "unknown"
report = Check_Report_GCP(
@@ -40,4 +44,31 @@ class logging_sink_created(Check):
report.status = "PASS"
report.status_extended = f"Sink {sink_name} is enabled exporting copies of all the log entries in project {project}."
findings.append(report)
elif covering_sink:
sink_name = getattr(covering_sink, "name", None) or "unknown"
report = Check_Report_GCP(
metadata=self.metadata(),
resource=covering_sink,
resource_id=sink_name,
project_id=project,
location=logging_client.region,
resource_name=(
sink_name if sink_name != "unknown" else "Logging Sink"
),
)
report.status = "PASS"
report.status_extended = f"Sink {sink_name} at organization level is exporting copies of all the log entries in project {project}."
findings.append(report)
else:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=project_obj,
resource_id=project,
project_id=project,
location=logging_client.region,
resource_name=(getattr(project_obj, "name", None) or "GCP Project"),
)
report.status = "FAIL"
report.status_extended = f"There are no logging sinks to export copies of all the log entries in project {project}."
findings.append(report)
return findings
+1 -1
View File
@@ -123,7 +123,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.13"
version = "5.29.2"
version = "5.29.3"
[project.scripts]
prowler = "prowler.__main__:prowler"
+83
View File
@@ -1004,6 +1004,89 @@ class TestJiraIntegration:
for mark in node.get("marks", [])
)
@staticmethod
def _find_empty_text_nodes(node) -> List[str]:
# ADF forbids empty text nodes; collect any to assert the document is valid.
empties: List[str] = []
def walk(current) -> None:
if isinstance(current, dict):
if current.get("type") == "text" and current.get("text", "") == "":
empties.append(current.get("text", ""))
for value in current.values():
walk(value)
elif isinstance(current, list):
for item in current:
walk(item)
walk(node)
return empties
def test_get_adf_description_empty_resource_name_has_no_empty_text_nodes(self):
# A resource without a name (e.g. an AWS-managed IAM policy) used to emit an
# empty ADF text node, making Jira reject the issue with 400 INVALID_INPUT.
adf_description = self.jira_integration.get_adf_description(
check_id="CHECK-1",
check_title="Sample check",
severity="CRITICAL",
severity_color="#FF0000",
status="FAIL",
status_color="#FF0000",
status_extended="Some status",
provider="aws",
region="eu-west-1",
resource_uid="arn:aws:iam::aws:policy/AdministratorAccess",
resource_name="",
recommendation_text="",
)
assert self._find_empty_text_nodes(adf_description) == []
table = adf_description["content"][1]
resource_name_row = self._find_table_row(table["content"], "Resource Name")
value_cell = resource_name_row["content"][1]
assert self._collect_text_from_cell(value_cell) == "-"
@pytest.mark.parametrize(
"field, header",
[
("check_id", "Check Id"),
("check_title", "Check Title"),
("status_extended", "Status Extended"),
("provider", "Provider"),
("region", "Region"),
("resource_uid", "Resource UID"),
("resource_name", "Resource Name"),
],
)
def test_get_adf_description_empty_plain_text_fields_render_placeholder(
self, field, header
):
base_kwargs = dict(
check_id="CHECK-1",
check_title="Sample check",
severity="HIGH",
severity_color="#FF0000",
status="FAIL",
status_color="#00FF00",
status_extended="Some status",
provider="aws",
region="us-east-1",
resource_uid="resource-1",
resource_name="resource-name",
recommendation_text="",
)
base_kwargs[field] = ""
adf_description = self.jira_integration.get_adf_description(**base_kwargs)
assert self._find_empty_text_nodes(adf_description) == []
table = adf_description["content"][1]
row = self._find_table_row(table["content"], header)
value_cell = row["content"][1]
assert self._collect_text_from_cell(value_cell) == "-"
@patch.object(Jira, "get_access_token", return_value="valid_access_token")
@patch.object(
Jira, "get_available_issue_types", return_value=["Bug", "Task", "Story"]
@@ -179,3 +179,60 @@ class Test_iam_service_account_unused:
assert result[1].project_id == GCP_PROJECT_ID
assert result[1].location == GCP_US_CENTER1_LOCATION
assert result[1].resource == iam_client.service_accounts[1]
def test_iam_service_account_disabled(self):
iam_client = mock.MagicMock()
monitoring_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.iam_client",
new=iam_client,
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.services.iam.iam_service import ServiceAccount
from prowler.providers.gcp.services.iam.iam_service_account_unused.iam_service_account_unused import (
iam_service_account_unused,
)
iam_client.project_ids = [GCP_PROJECT_ID]
iam_client.region = GCP_US_CENTER1_LOCATION
iam_client.service_accounts = [
ServiceAccount(
name="projects/my-project/serviceAccounts/disabled-sa@my-project.iam.gserviceaccount.com",
email="disabled-sa@my-project.iam.gserviceaccount.com",
display_name="Disabled service account",
keys=[],
project_id=GCP_PROJECT_ID,
uniqueId="999888877776666",
disabled=True,
)
]
# The account is absent from the usage metrics, so a non-disabled
# account here would FAIL. Being disabled must take precedence and
# PASS, since a disabled account cannot authenticate or be used.
monitoring_client.sa_api_metrics = set()
monitoring_client.audit_config = {"max_unused_account_days": 30}
check = iam_service_account_unused()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Service Account {iam_client.service_accounts[0].email} is disabled and cannot be used."
)
assert result[0].resource_id == iam_client.service_accounts[0].email
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].resource == iam_client.service_accounts[0]
@@ -259,3 +259,176 @@ class Test_logging_log_metric_filter_and_alert_for_audit_configuration_changes_e
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_project_centrally_covered_via_org_aggregated_sink(self):
"""A child project with NO local metric, but whose org has an aggregated
sink (includeChildren=True) routing its logs to a central bucket that has
a bucket-scoped metric + alert, should PASS (covered centrally) instead of
being falsely failed."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import (
logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
from prowler.providers.gcp.services.monitoring.monitoring_service import (
AlertPolicy,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
# Bucket-scoped central metric, in the scanned logging project.
logging_client.metrics = [
Metric(
name="central-audit-config-metric",
type="logging.googleapis.com/user/central-audit-config-metric",
filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
# Org-level aggregated sink routing the child's logs to that bucket.
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
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-audit-config-metric"'
],
project_id="central-logging-project",
)
]
check = (
logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled()
)
result = check.execute()
assert any(
r.project_id == GCP_PROJECT_ID
and r.status == "PASS"
and "aggregated sink" in r.status_extended
for r in result
), [(r.project_id, r.status, r.status_extended) for r in result]
def test_aggregated_sink_metric_without_alert_still_fails(self):
"""Guard: an org aggregated sink + a bucket-scoped metric matching the filter
but with NO alert must NOT credit the child project it should still FAIL."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import (
logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
monitoring_client.alert_policies = [] # no alert -> must NOT credit
check = (
logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled()
)
result = check.execute()
child = [r for r in result if r.project_id == GCP_PROJECT_ID]
assert child and all(r.status == "FAIL" for r in child), [
(r.project_id, r.status) for r in result
]
@@ -397,3 +397,173 @@ class Test_logging_log_metric_filter_and_alert_for_bucket_permission_changes_ena
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_project_centrally_covered_via_org_aggregated_sink(self):
"""A child project with NO local metric, but whose org has an aggregated
sink (includeChildren=True) routing its logs to a central bucket that has
a bucket-scoped metric + alert, should PASS (covered centrally)."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import (
logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
from prowler.providers.gcp.services.monitoring.monitoring_service import (
AlertPolicy,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
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",
)
]
check = (
logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled()
)
result = check.execute()
assert any(
r.project_id == GCP_PROJECT_ID
and r.status == "PASS"
and "aggregated sink" in r.status_extended
for r in result
), [(r.project_id, r.status, r.status_extended) for r in result]
def test_aggregated_sink_metric_without_alert_still_fails(self):
"""Guard: an org aggregated sink + a bucket-scoped metric matching the filter
but with NO alert must NOT credit the child project it should still FAIL."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import (
logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
monitoring_client.alert_policies = [] # no alert -> must NOT credit
check = (
logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled()
)
result = check.execute()
child = [r for r in result if r.project_id == GCP_PROJECT_ID]
assert child and all(r.status == "FAIL" for r in child), [
(r.project_id, r.status) for r in result
]
@@ -346,3 +346,173 @@ class Test_logging_log_metric_filter_and_alert_for_compute_configuration_changes
fail_result = [r for r in result if r.status == "FAIL"][0]
assert fail_result.project_id == project_id_2
assert "no log metric filters" in fail_result.status_extended
def test_project_centrally_covered_via_org_aggregated_sink(self):
"""A child project with NO local metric, but whose org has an aggregated
sink (includeChildren=True) routing its logs to a central bucket that has
a bucket-scoped metric + alert, should PASS (covered centrally)."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import (
logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
from prowler.providers.gcp.services.monitoring.monitoring_service import (
AlertPolicy,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='protoPayload.serviceName="compute.googleapis.com"',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
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",
)
]
check = (
logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled()
)
result = check.execute()
assert any(
r.project_id == GCP_PROJECT_ID
and r.status == "PASS"
and "aggregated sink" in r.status_extended
for r in result
), [(r.project_id, r.status, r.status_extended) for r in result]
def test_aggregated_sink_metric_without_alert_still_fails(self):
"""Guard: an org aggregated sink + a bucket-scoped metric matching the filter
but with NO alert must NOT credit the child project it should still FAIL."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled import (
logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='protoPayload.serviceName="compute.googleapis.com"',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
monitoring_client.alert_policies = [] # no alert -> must NOT credit
check = (
logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled()
)
result = check.execute()
child = [r for r in result if r.project_id == GCP_PROJECT_ID]
assert child and all(r.status == "FAIL" for r in child), [
(r.project_id, r.status) for r in result
]
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_custom_role_changes_enabled:
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_project_centrally_covered_via_org_aggregated_sink(self):
"""A child project with NO local metric, but whose org has an aggregated
sink (includeChildren=True) routing its logs to a central bucket that has
a bucket-scoped metric + alert, should PASS (covered centrally)."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import (
logging_log_metric_filter_and_alert_for_custom_role_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
from prowler.providers.gcp.services.monitoring.monitoring_service import (
AlertPolicy,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
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",
)
]
check = (
logging_log_metric_filter_and_alert_for_custom_role_changes_enabled()
)
result = check.execute()
assert any(
r.project_id == GCP_PROJECT_ID
and r.status == "PASS"
and "aggregated sink" in r.status_extended
for r in result
), [(r.project_id, r.status, r.status_extended) for r in result]
def test_aggregated_sink_metric_without_alert_still_fails(self):
"""Guard: an org aggregated sink + a bucket-scoped metric matching the filter
but with NO alert must NOT credit the child project it should still FAIL."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import (
logging_log_metric_filter_and_alert_for_custom_role_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="iam_role" AND (protoPayload.methodName="google.iam.admin.v1.CreateRole" OR protoPayload.methodName="google.iam.admin.v1.DeleteRole" OR protoPayload.methodName="google.iam.admin.v1.UpdateRole")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
monitoring_client.alert_policies = [] # no alert -> must NOT credit
check = (
logging_log_metric_filter_and_alert_for_custom_role_changes_enabled()
)
result = check.execute()
child = [r for r in result if r.project_id == GCP_PROJECT_ID]
assert child and all(r.status == "FAIL" for r in child), [
(r.project_id, r.status) for r in result
]
@@ -392,3 +392,173 @@ class Test_logging_log_metric_filter_and_alert_for_project_ownership_changes_ena
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_project_centrally_covered_via_org_aggregated_sink(self):
"""A child project with NO local metric, but whose org has an aggregated
sink (includeChildren=True) routing its logs to a central bucket that has
a bucket-scoped metric + alert, should PASS (covered centrally)."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import (
logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
from prowler.providers.gcp.services.monitoring.monitoring_service import (
AlertPolicy,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
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",
)
]
check = (
logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled()
)
result = check.execute()
assert any(
r.project_id == GCP_PROJECT_ID
and r.status == "PASS"
and "aggregated sink" in r.status_extended
for r in result
), [(r.project_id, r.status, r.status_extended) for r in result]
def test_aggregated_sink_metric_without_alert_still_fails(self):
"""Guard: an org aggregated sink + a bucket-scoped metric matching the filter
but with NO alert must NOT credit the child project it should still FAIL."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import (
logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='(protoPayload.serviceName="cloudresourcemanager.googleapis.com") AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="REMOVE" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner") OR (protoPayload.serviceData.policyDelta.bindingDeltas.action="ADD" AND protoPayload.serviceData.policyDelta.bindingDeltas.role="roles/owner")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
monitoring_client.alert_policies = [] # no alert -> must NOT credit
check = (
logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled()
)
result = check.execute()
child = [r for r in result if r.project_id == GCP_PROJECT_ID]
assert child and all(r.status == "FAIL" for r in child), [
(r.project_id, r.status) for r in result
]
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_sql_instance_configuration_ch
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_project_centrally_covered_via_org_aggregated_sink(self):
"""A child project with NO local metric, but whose org has an aggregated
sink (includeChildren=True) routing its logs to a central bucket that has
a bucket-scoped metric + alert, should PASS (covered centrally)."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import (
logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
from prowler.providers.gcp.services.monitoring.monitoring_service import (
AlertPolicy,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='protoPayload.methodName="cloudsql.instances.update"',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
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",
)
]
check = (
logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled()
)
result = check.execute()
assert any(
r.project_id == GCP_PROJECT_ID
and r.status == "PASS"
and "aggregated sink" in r.status_extended
for r in result
), [(r.project_id, r.status, r.status_extended) for r in result]
def test_aggregated_sink_metric_without_alert_still_fails(self):
"""Guard: an org aggregated sink + a bucket-scoped metric matching the filter
but with NO alert must NOT credit the child project it should still FAIL."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import (
logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='protoPayload.methodName="cloudsql.instances.update"',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
monitoring_client.alert_policies = [] # no alert -> must NOT credit
check = (
logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled()
)
result = check.execute()
child = [r for r in result if r.project_id == GCP_PROJECT_ID]
assert child and all(r.status == "FAIL" for r in child), [
(r.project_id, r.status) for r in result
]
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_ena
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_project_centrally_covered_via_org_aggregated_sink(self):
"""A child project with NO local metric, but whose org has an aggregated
sink (includeChildren=True) routing its logs to a central bucket that has
a bucket-scoped metric + alert, should PASS (covered centrally)."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import (
logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
from prowler.providers.gcp.services.monitoring.monitoring_service import (
AlertPolicy,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
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",
)
]
check = (
logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled()
)
result = check.execute()
assert any(
r.project_id == GCP_PROJECT_ID
and r.status == "PASS"
and "aggregated sink" in r.status_extended
for r in result
), [(r.project_id, r.status, r.status_extended) for r in result]
def test_aggregated_sink_metric_without_alert_still_fails(self):
"""Guard: an org aggregated sink + a bucket-scoped metric matching the filter
but with NO alert must NOT credit the child project it should still FAIL."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import (
logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="gce_firewall_rule" AND (protoPayload.methodName:"compute.firewalls.patch" OR protoPayload.methodName:"compute.firewalls.insert" OR protoPayload.methodName:"compute.firewalls.delete")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
monitoring_client.alert_policies = [] # no alert -> must NOT credit
check = (
logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled()
)
result = check.execute()
child = [r for r in result if r.project_id == GCP_PROJECT_ID]
assert child and all(r.status == "FAIL" for r in child), [
(r.project_id, r.status) for r in result
]
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled:
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_project_centrally_covered_via_org_aggregated_sink(self):
"""A child project with NO local metric, but whose org has an aggregated
sink (includeChildren=True) routing its logs to a central bucket that has
a bucket-scoped metric + alert, should PASS (covered centrally)."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import (
logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
from prowler.providers.gcp.services.monitoring.monitoring_service import (
AlertPolicy,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
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",
)
]
check = (
logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled()
)
result = check.execute()
assert any(
r.project_id == GCP_PROJECT_ID
and r.status == "PASS"
and "aggregated sink" in r.status_extended
for r in result
), [(r.project_id, r.status, r.status_extended) for r in result]
def test_aggregated_sink_metric_without_alert_still_fails(self):
"""Guard: an org aggregated sink + a bucket-scoped metric matching the filter
but with NO alert must NOT credit the child project it should still FAIL."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import (
logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="gce_network" AND (protoPayload.methodName:"compute.networks.insert" OR protoPayload.methodName:"compute.networks.patch" OR protoPayload.methodName:"compute.networks.delete" OR protoPayload.methodName:"compute.networks.removePeering" OR protoPayload.methodName:"compute.networks.addPeering")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
monitoring_client.alert_policies = [] # no alert -> must NOT credit
check = (
logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled()
)
result = check.execute()
child = [r for r in result if r.project_id == GCP_PROJECT_ID]
assert child and all(r.status == "FAIL" for r in child), [
(r.project_id, r.status) for r in result
]
@@ -259,3 +259,173 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_route_changes_ena
assert result[0].resource_name == "metric_name"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_project_centrally_covered_via_org_aggregated_sink(self):
"""A child project with NO local metric, but whose org has an aggregated
sink (includeChildren=True) routing its logs to a central bucket that has
a bucket-scoped metric + alert, should PASS (covered centrally)."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import (
logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
from prowler.providers.gcp.services.monitoring.monitoring_service import (
AlertPolicy,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
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",
)
]
check = (
logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled()
)
result = check.execute()
assert any(
r.project_id == GCP_PROJECT_ID
and r.status == "PASS"
and "aggregated sink" in r.status_extended
for r in result
), [(r.project_id, r.status, r.status_extended) for r in result]
def test_aggregated_sink_metric_without_alert_still_fails(self):
"""Guard: an org aggregated sink + a bucket-scoped metric matching the filter
but with NO alert must NOT credit the child project it should still FAIL."""
logging_client = MagicMock()
monitoring_client = MagicMock()
org_id = "111222333"
central_bucket = (
"projects/central-logging-project/locations/eu/buckets/central-bucket"
)
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client",
new=logging_client,
),
patch(
"prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import (
logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled,
)
from prowler.providers.gcp.services.logging.logging_service import (
Metric,
Sink,
)
logging_client.region = GCP_EU1_LOCATION
logging_client.project_ids = [GCP_PROJECT_ID, "central-logging-project"]
logging_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="child",
labels={},
lifecycle_state="ACTIVE",
organization=GCPOrganization(
id=org_id, name=f"organizations/{org_id}"
),
)
}
logging_client.metrics = [
Metric(
name="central-metric",
type="logging.googleapis.com/user/central-metric",
filter='resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")',
project_id="central-logging-project",
bucket_name=central_bucket,
)
]
logging_client.sinks = [
Sink(
name="org-aggregated-sink",
destination=f"logging.googleapis.com/{central_bucket}",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
monitoring_client.alert_policies = [] # no alert -> must NOT credit
check = (
logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled()
)
result = check.execute()
child = [r for r in result if r.project_id == GCP_PROJECT_ID]
assert child and all(r.status == "FAIL" for r in child), [
(r.project_id, r.status) for r in result
]
@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from prowler.providers.gcp.services.logging.logging_service import Logging
from tests.providers.gcp.gcp_fixtures import (
@@ -66,3 +66,237 @@ class TestLoggingService:
== "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_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) == {}
@@ -1,6 +1,6 @@
from unittest.mock import MagicMock, patch
from prowler.providers.gcp.models import GCPProject
from prowler.providers.gcp.models import GCPOrganization, GCPProject
from tests.providers.gcp.gcp_fixtures import (
GCP_EU1_LOCATION,
GCP_PROJECT_ID,
@@ -268,6 +268,7 @@ class Test_logging_sink_created:
sink.name = None
sink.filter = "all"
sink.project_id = GCP_PROJECT_ID
sink.include_children = False
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
@@ -311,9 +312,10 @@ class Test_logging_sink_created:
)
# Create a MagicMock sink object without name attribute
sink = MagicMock(spec=["filter", "project_id"])
sink = MagicMock(spec=["filter", "project_id", "include_children"])
sink.filter = "all"
sink.project_id = GCP_PROJECT_ID
sink.include_children = False
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
@@ -336,3 +338,175 @@ class Test_logging_sink_created:
assert result[0].resource_id == "unknown"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_org_level_sink_with_include_children_passes(self):
"""Projects covered by an org-level sink with includeChildren=True should PASS."""
logging_client = MagicMock()
org_id = "111222333"
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
new=logging_client,
),
):
from prowler.providers.gcp.services.logging.logging_service import Sink
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
logging_sink_created,
)
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
logging_client.sinks = [
Sink(
name="org-sink",
destination="storage.googleapis.com/org-bucket",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
)
]
logging_client.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}"
),
)
}
check = logging_sink_created()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Sink org-sink at organization level is exporting copies of all the log entries in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == "org-sink"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_EU1_LOCATION
def test_org_level_sink_without_include_children_fails(self):
"""Projects NOT covered by includeChildren should still FAIL if no direct project sink."""
logging_client = MagicMock()
org_id = "111222333"
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
new=logging_client,
),
):
from prowler.providers.gcp.services.logging.logging_service import Sink
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
logging_sink_created,
)
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
logging_client.sinks = [
Sink(
name="org-sink-no-children",
destination="storage.googleapis.com/org-bucket",
filter="all",
project_id=f"organizations/{org_id}",
include_children=False,
)
]
logging_client.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}"
),
)
}
check = logging_sink_created()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].project_id == GCP_PROJECT_ID
def test_project_sink_takes_precedence_over_org_sink(self):
"""A direct project sink should be reported even when an org-level sink also covers the project."""
logging_client = MagicMock()
org_id = "111222333"
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
patch(
"prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client",
new=logging_client,
),
):
from prowler.providers.gcp.services.logging.logging_service import Sink
from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import (
logging_sink_created,
)
logging_client.project_ids = [GCP_PROJECT_ID]
logging_client.region = GCP_EU1_LOCATION
logging_client.sinks = [
Sink(
name="project-sink",
destination="storage.googleapis.com/project-bucket",
filter="all",
project_id=GCP_PROJECT_ID,
),
Sink(
name="org-sink",
destination="storage.googleapis.com/org-bucket",
filter="all",
project_id=f"organizations/{org_id}",
include_children=True,
),
]
logging_client.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}"
),
)
}
check = logging_sink_created()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Sink project-sink is enabled exporting copies of all the log entries in project {GCP_PROJECT_ID}."
)
assert result[0].resource_id == "project-sink"
assert result[0].project_id == GCP_PROJECT_ID
+8
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.29.3] (Prowler v5.29.3)
### 🐞 Fixed
- Finding drawer tabs now keep the active tab text and underline styling when tooltip state changes [(#11493)](https://github.com/prowler-cloud/prowler/pull/11493)
---
## [1.29.2] (Prowler v5.29.2)
### 🔄 Changed
+27
View File
@@ -0,0 +1,27 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Tabs, TabsList, TabsTrigger } from "./tabs";
describe("TabsTrigger", () => {
it("keeps active styling available when rendered with a tooltip", () => {
render(
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview" tooltip="Overview">
Overview
</TabsTrigger>
<TabsTrigger value="remediation" tooltip="Remediation">
Remediation
</TabsTrigger>
</TabsList>
</Tabs>,
);
const activeTrigger = screen.getByRole("tab", { name: "Overview" });
expect(activeTrigger).toHaveAttribute("aria-selected", "true");
expect(activeTrigger).toHaveClass("aria-selected:text-slate-900");
expect(activeTrigger).toHaveClass("aria-selected:after:scale-x-100");
});
});
+2 -2
View File
@@ -18,9 +18,9 @@ const TRIGGER_STYLES = {
border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]",
text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white",
active:
"data-[state=active]:text-slate-900 dark:data-[state=active]:text-white",
"data-[state=active]:text-slate-900 aria-selected:text-slate-900 dark:data-[state=active]:text-white dark:aria-selected:text-white",
underline:
"after:absolute after:bottom-0 after:left-0 after:right-4 after:h-0.5 after:scale-x-0 after:bg-emerald-400 after:transition-transform data-[state=active]:after:scale-x-100 [&:not(:first-child)]:after:left-4 [&:last-child]:after:right-0",
"after:absolute after:bottom-0 after:left-0 after:right-4 after:h-0.5 after:scale-x-0 after:bg-emerald-400 after:transition-transform data-[state=active]:after:scale-x-100 aria-selected:after:scale-x-100 [&:not(:first-child)]:after:left-4 [&:last-child]:after:right-0",
focus:
"focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950",
icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
Generated
+1 -1
View File
@@ -3241,7 +3241,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.29.2"
version = "5.29.3"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },