Compare commits

..

3 Commits

Author SHA1 Message Date
pedrooot 70db6f3d74 fix(deps): bump dulwich to 1.2.6 to fix GHSA-897w-fcg9-f6xj 2026-06-02 11:00:39 +02:00
Hugo Pereira Brito 1b17304c4a docs(installation): add PowerShell commands for Prowler App install (#11413) 2026-06-02 09:17:40 +01:00
Prowler Bot c2cef99b33 chore(release): Bump versions to v5.30.0 (#11418)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-01 18:37:51 +02:00
80 changed files with 833 additions and 4366 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.4
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+12
View File
@@ -153,6 +153,8 @@ Prowler App offers flexible installation methods tailored to various environment
#### Commands
_macOS/Linux:_
``` console
VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name)
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml"
@@ -161,6 +163,16 @@ curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${V
docker compose up -d
```
_Windows PowerShell:_
``` powershell
$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
docker compose up -d
```
> [!WARNING]
> 🔒 For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
-17
View File
@@ -2,23 +2,6 @@
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
- `GET /api/v1/findings` N+1 query loading `resources__tags` when listing findings [(#11420)](https://github.com/prowler-cloud/prowler/pull/11420)
- Clean up the scan tmp output directory when `scan-report` fails so partial files do not accumulate and fill the worker disk (`No space left on device`) [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
---
## [1.30.0] (Prowler v5.29.0)
### 🔄 Changed
+2 -2
View File
@@ -43,7 +43,7 @@ dependencies = [
"defusedxml==0.7.1",
"gunicorn==23.0.0",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.29",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -68,7 +68,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.30.4"
version = "1.31.0"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+34 -6
View File
@@ -1,14 +1,12 @@
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)
@@ -32,6 +30,7 @@ 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
@@ -42,8 +41,37 @@ class ApiConfig(AppConfig):
):
self._ensure_crypto_keys()
# 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.
# 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
def _ensure_crypto_keys(self):
"""
+4 -18
View File
@@ -1,24 +1,22 @@
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
@@ -30,9 +28,6 @@ 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",
@@ -63,24 +58,15 @@ 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,
)
# 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
_driver.verify_connectivity()
# 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.4
version: 1.31.0
description: |-
Prowler API specification.
+44 -12
View File
@@ -182,19 +182,23 @@ def _make_app():
return ApiConfig("api", api)
@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."""
def test_ready_initializes_driver_for_api_process(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, argv)
_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_testing(monkeypatch, False)
with (
@@ -204,3 +208,31 @@ def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv):
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,16 +1,15 @@
"""
Tests for Neo4j database lazy initialization.
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.
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.
"""
import threading
from unittest.mock import MagicMock, patch
import neo4j
import neo4j.exceptions
import pytest
import api.attack_paths.database as db_module
@@ -60,32 +59,6 @@ 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(
@@ -143,23 +116,21 @@ class TestConnectionAcquisitionTimeout:
@pytest.fixture(autouse=True)
def reset_module_state(self):
original_driver = db_module._driver
original_acq_timeout = db_module.CONN_ACQUISITION_TIMEOUT
original_conn_timeout = db_module.CONNECTION_TIMEOUT
original_timeout = db_module.CONN_ACQUISITION_TIMEOUT
db_module._driver = None
yield
db_module._driver = original_driver
db_module.CONN_ACQUISITION_TIMEOUT = original_acq_timeout
db_module.CONNECTION_TIMEOUT = original_conn_timeout
db_module.CONN_ACQUISITION_TIMEOUT = original_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 the configured timeouts to the neo4j driver."""
"""init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver."""
mock_driver_factory.return_value = MagicMock()
mock_settings.DATABASES = {
"neo4j": {
@@ -170,13 +141,11 @@ 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:
+13 -228
View File
@@ -24,11 +24,9 @@ from conftest import (
today_after_n_days,
)
from django.conf import settings
from django.db import connection
from django.db.models import Count
from django.http import JsonResponse
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from django_celery_results.models import TaskResult
from rest_framework import status
@@ -66,7 +64,6 @@ from api.models import (
ProviderSecret,
Resource,
ResourceFindingMapping,
ResourceTag,
Role,
RoleProviderGroupRelationship,
SAMLConfiguration,
@@ -3859,20 +3856,16 @@ class TestScanViewSet:
scan.output_location = "dummy"
scan.save()
task_result = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
)
task = Task.objects.create(
tenant_id=scan.tenant_id,
task_runner_task=task_result,
)
dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING}
dummy_task = Task.objects.create(tenant_id=scan.tenant_id)
dummy_task.id = "dummy-task-id"
dummy_task_data = {"id": dummy_task.id, "state": StateChoices.EXECUTING}
with patch(
"api.v1.views.TaskSerializer",
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
with (
patch("api.v1.views.Task.objects.get", return_value=dummy_task),
patch(
"api.v1.views.TaskSerializer",
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
),
):
url = reverse("scan-report", kwargs={"pk": scan.id})
response = authenticated_client.get(url)
@@ -4193,88 +4186,6 @@ class TestScanViewSet:
assert resp.status_code == status.HTTP_302_FOUND
assert resp["Location"] == presigned_url
def test_compliance_s3_returns_latest_match(
self, authenticated_client, scans_fixture, monkeypatch
):
"""When several files match, the most recently modified one is served."""
scan = scans_fixture[0]
bucket = "bucket"
scan.output_location = f"s3://{bucket}/path/scan.zip"
scan.state = StateChoices.COMPLETED
scan.save()
monkeypatch.setattr(
"api.v1.views.env",
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
)
old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv"
latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv"
class FakeS3Client:
def list_objects_v2(self, Bucket, Prefix):
return {
"Contents": [
{
"Key": old_key,
"LastModified": datetime(2024, 1, 1, tzinfo=timezone.utc),
},
{
"Key": latest_key,
"LastModified": datetime(2024, 2, 2, tzinfo=timezone.utc),
},
]
}
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
assert Params["Key"] == latest_key
return "https://test-bucket.s3.amazonaws.com/latest"
monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())
url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"})
resp = authenticated_client.get(url)
assert resp.status_code == status.HTTP_302_FOUND
assert resp["Location"].endswith("/latest")
def test_compliance_local_returns_latest_match(
self, authenticated_client, scans_fixture, monkeypatch
):
"""The local branch serves the most recently modified matching file."""
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
with tempfile.TemporaryDirectory() as tmp:
comp_dir = Path(tmp) / "reports" / "compliance"
comp_dir.mkdir(parents=True, exist_ok=True)
old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv"
old_file.write_bytes(b"old")
latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv"
latest_file.write_bytes(b"latest")
# Make `latest_file` newer regardless of creation order.
os.utime(old_file, (1_700_000_000, 1_700_000_000))
os.utime(latest_file, (1_700_000_100, 1_700_000_100))
scan.output_location = str(Path(tmp) / "reports" / "scan.zip")
scan.save()
monkeypatch.setattr(
glob,
"glob",
lambda p: [str(old_file), str(latest_file)],
)
url = reverse(
"scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}
)
resp = authenticated_client.get(url)
assert resp.status_code == status.HTTP_200_OK
assert resp.content == b"latest"
assert resp["Content-Disposition"].endswith(
f'filename="{latest_file.name}"'
)
def test_compliance_s3_not_found(
self, authenticated_client, scans_fixture, monkeypatch
):
@@ -4383,24 +4294,18 @@ class TestScanViewSet:
assert cd.startswith('attachment; filename="')
assert cd.endswith(f'filename="{fname.name}"')
@patch("api.v1.views.Task.objects.get")
@patch("api.v1.views.TaskSerializer")
def test__get_task_status_returns_none_if_task_not_executing(
self, mock_task_serializer, authenticated_client, scans_fixture
self, mock_task_serializer, mock_task_get, authenticated_client, scans_fixture
):
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
scan.output_location = "dummy"
scan.save()
task_result = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
)
task = Task.objects.create(
tenant_id=scan.tenant_id,
task_runner_task=task_result,
)
task = Task.objects.create(tenant_id=scan.tenant_id)
mock_task_get.return_value = task
mock_task_serializer.return_value.data = {
"id": str(task.id),
"state": StateChoices.COMPLETED,
@@ -4421,7 +4326,6 @@ class TestScanViewSet:
scan.save()
task_result = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
)
@@ -4442,51 +4346,6 @@ class TestScanViewSet:
assert response.status_code == status.HTTP_202_ACCEPTED
assert response.data["id"] == str(task.id)
@patch("api.v1.views.TaskSerializer")
def test__get_task_status_returns_latest_task(
self, mock_task_serializer, authenticated_client, scans_fixture
):
"""With several scan-report tasks for the scan, the most recent is used."""
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
scan.output_location = "dummy"
scan.save()
old_task = Task.objects.create(
tenant_id=scan.tenant_id,
task_runner_task=TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
),
)
new_task = Task.objects.create(
tenant_id=scan.tenant_id,
task_runner_task=TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
),
)
# `inserted_at` is `auto_now_add`, and within the test transaction the DB
# `now()` is constant, so force distinct timestamps to make order_by stable.
base = datetime(2024, 1, 1, tzinfo=timezone.utc)
Task.objects.filter(pk=old_task.pk).update(inserted_at=base)
Task.objects.filter(pk=new_task.pk).update(
inserted_at=base + timedelta(hours=1)
)
mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace(
data={"id": str(instance.id), "state": StateChoices.EXECUTING}
)
url = reverse("scan-report", kwargs={"pk": scan.id})
response = authenticated_client.get(url)
assert response.status_code == status.HTTP_202_ACCEPTED
assert str(new_task.id) in response["Content-Location"]
assert str(old_task.id) not in response["Content-Location"]
@patch("api.v1.views.get_s3_client")
@patch("api.v1.views.sentry_sdk.capture_exception")
def test_compliance_list_objects_client_error(
@@ -7057,80 +6916,6 @@ class TestFindingViewSet:
== findings_fixture[0].status
)
def test_findings_list_resource_tags_no_n_plus_one(
self, authenticated_client, findings_fixture
):
"""Listing findings must load every resource's tags in a constant
number of queries, no matter how many findings/resources are returned.
This guards ``FindingViewSet._optimize_tags_loading`` against
regressions that would reintroduce one extra query per resource (the
N+1 the prefetch was added to remove).
"""
scan = findings_fixture[0].scan
tenant_id = findings_fixture[0].tenant_id
provider = scan.provider
def _create_finding_with_tagged_resource(index):
resource = Resource.objects.create(
tenant_id=tenant_id,
provider=provider,
uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}",
name=f"N+1 Instance {index}",
region="us-east-1",
service="ec2",
type="prowler-test",
)
resource.upsert_or_delete_tags(
[
ResourceTag.objects.create(
tenant_id=tenant_id,
key=f"key-{index}",
value=f"value-{index}",
)
]
)
finding = Finding.objects.create(
tenant_id=tenant_id,
uid=f"n_plus_one_finding_{index}",
scan=scan,
status=Status.FAIL,
status_extended="n+1 status",
impact=Severity.medium,
severity=Severity.medium,
check_id="test_check_id",
check_metadata={"CheckId": "test_check_id", "servicename": "ec2"},
first_seen_at="2024-01-02T00:00:00Z",
)
finding.add_resources([resource])
return finding
params = {"filter[inserted_at]": TODAY, "include": "resources"}
# Baseline: the two findings provided by the fixture.
with CaptureQueriesContext(connection) as baseline:
response = authenticated_client.get(reverse("finding-list"), params)
assert response.status_code == status.HTTP_200_OK
# Add more findings, each with its own resource carrying tags.
extra_findings = 5
for index in range(extra_findings):
_create_finding_with_tagged_resource(index)
with CaptureQueriesContext(connection) as scaled:
response = authenticated_client.get(reverse("finding-list"), params)
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == len(findings_fixture) + extra_findings
# The query count must not grow with the number of findings/resources.
assert len(scaled.captured_queries) == len(baseline.captured_queries), (
"Resource tags are not being prefetched: "
f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} "
f"findings vs {len(scaled.captured_queries)} for "
f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression "
"in FindingViewSet._optimize_tags_loading."
)
@pytest.mark.parametrize(
"include_values, expected_resources",
[
+11 -33
View File
@@ -2059,17 +2059,12 @@ class ScanViewSet(BaseRLSViewSet):
if scan_instance.state == StateChoices.EXECUTING and scan_instance.task:
task = scan_instance.task
else:
# A scan can have several `scan-report` tasks (e.g. re-runs); take the
# most recent one. `.first()` also avoids `MultipleObjectsReturned`.
task = (
Task.objects.filter(
try:
task = Task.objects.get(
task_runner_task__task_name="scan-report",
task_runner_task__task_kwargs__contains=str(scan_instance.id),
)
.order_by("-inserted_at")
.first()
)
if task is None:
except Task.DoesNotExist:
return None
self.response_serializer_class = TaskSerializer
@@ -2144,32 +2139,27 @@ class ScanViewSet(BaseRLSViewSet):
status=status.HTTP_502_BAD_GATEWAY,
)
contents = resp.get("Contents", [])
matches = []
keys = []
for obj in contents:
key = obj["Key"]
key_basename = os.path.basename(key)
if any(ch in suffix for ch in ("*", "?", "[")):
if fnmatch.fnmatch(key_basename, suffix):
matches.append(obj)
keys.append(key)
elif key_basename == suffix:
matches.append(obj)
keys.append(key)
elif key.endswith(suffix):
# Backward compatibility if suffix already includes directories
matches.append(obj)
if not matches:
keys.append(key)
if not keys:
return Response(
{
"detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'."
},
status=status.HTTP_404_NOT_FOUND,
)
# Return the most recently modified match (latest report) when
# several files share the prefix/suffix. `list_objects_v2` always
# returns `LastModified`; the fallback keeps ordering deterministic
# if it is ever absent.
key = max(matches, key=lambda o: (o.get("LastModified", ""), o["Key"]))[
"Key"
]
# path_pattern here is prefix, but in compliance we build correct suffix check before
key = keys[0]
else:
# path_pattern is exact key; HEAD before presigning to preserve the 404 contract.
key = path_pattern
@@ -2219,9 +2209,7 @@ class ScanViewSet(BaseRLSViewSet):
},
status=status.HTTP_404_NOT_FOUND,
)
# Return the most recently modified match (latest report) when the
# pattern resolves to several files.
filepath = max(files, key=os.path.getmtime)
filepath = files[0]
with open(filepath, "rb") as f:
content = f.read()
filename = os.path.basename(filepath)
@@ -3761,16 +3749,6 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
return queryset
return super().filter_queryset(queryset)
def _optimize_tags_loading(self, queryset):
"""Prefetch resource tags to avoid N+1 queries when serializing findings"""
return queryset.prefetch_related(
Prefetch(
"resources__tags",
queryset=ResourceTag.objects.filter(tenant_id=self.request.tenant_id),
to_attr="prefetched_tags",
)
)
def list(self, request, *args, **kwargs):
filtered_queryset = self.filter_queryset(self.get_queryset())
return self.paginate_by_pk(
+2 -28
View File
@@ -467,31 +467,8 @@ def delete_tenant_task(tenant_id: str):
return delete_tenant(pk=tenant_id)
def _scan_tmp_output_directory(tenant_id: str, scan_id: str) -> Path:
"""Root tmp output directory for a scan ({tmp}/{tenant_id}/{scan_id})."""
return Path(DJANGO_TMP_OUTPUT_DIRECTORY) / str(tenant_id) / str(scan_id)
class ScanReportRLSTask(RLSTask):
"""
RLS task that removes the scan's tmp output directory when the task fails.
Covers failures both inside and outside the task body (e.g. ENOSPC mid-write,
or setup errors) so partial artifacts do not accumulate on the worker disk.
"""
def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002
del args # Required by Celery's Task.on_failure signature; not used.
tenant_id = kwargs.get("tenant_id")
scan_id = kwargs.get("scan_id")
if tenant_id and scan_id:
logger.error(f"Scan report task {task_id} failed: {exc}")
rmtree(_scan_tmp_output_directory(tenant_id, scan_id), ignore_errors=True)
@shared_task(
base=ScanReportRLSTask,
base=RLSTask,
name="scan-report",
queue="scan-reports",
)
@@ -541,9 +518,6 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
out_dir, comp_dir = _generate_output_directory(
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
)
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
def get_writer(writer_map, name, factory, is_last):
"""
@@ -692,7 +666,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
# TODO: We need to create a new periodic task to delete the output files
# This task shouldn't be responsible for deleting the output files
try:
rmtree(scan_tmp_dir, ignore_errors=True)
rmtree(Path(compressed).parent, ignore_errors=True)
except Exception as e:
logger.error(f"Error deleting output files: {e}")
final_location, did_upload = upload_uri, True
-34
View File
@@ -15,10 +15,8 @@ from tasks.jobs.lighthouse_providers import (
from tasks.tasks import (
DJANGO_TMP_OUTPUT_DIRECTORY,
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
ScanReportRLSTask,
_cleanup_orphan_scheduled_scans,
_perform_scan_complete_tasks,
_scan_tmp_output_directory,
check_integrations_task,
check_lighthouse_provider_connection_task,
generate_outputs_task,
@@ -773,38 +771,6 @@ class TestGenerateOutputs:
mock_s3_task.assert_called_once()
class TestScanReportRLSTaskOnFailure:
def test_on_failure_removes_scan_tmp_directory(self):
task = ScanReportRLSTask()
with patch("tasks.tasks.rmtree") as mock_rmtree:
task.on_failure(
exc=OSError("No space left on device"),
task_id="task-abc",
args=(),
kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
_einfo=None,
)
mock_rmtree.assert_called_once_with(
_scan_tmp_output_directory("t-1", "s-1"), ignore_errors=True
)
def test_on_failure_skips_when_missing_kwargs(self):
task = ScanReportRLSTask()
with patch("tasks.tasks.rmtree") as mock_rmtree:
task.on_failure(
exc=OSError("No space left on device"),
task_id="task-abc",
args=(),
kwargs={},
_einfo=None,
)
mock_rmtree.assert_not_called()
class TestScanCompleteTasks:
@patch("tasks.tasks.aggregate_attack_surface_task.apply_async")
@patch("tasks.tasks.chain")
Generated
+4 -54
View File
@@ -4410,8 +4410,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.29.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29#a769e3761532d9332cb64078ef09ebf7ffb15292" }
version = "5.27.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4484,13 +4484,9 @@ dependencies = [
{ name = "pygithub" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "scaleway" },
{ name = "schema" },
{ name = "shodan" },
{ name = "slack-sdk" },
{ name = "stackit-core" },
{ name = "stackit-iaas" },
{ name = "stackit-resourcemanager" },
{ name = "tabulate" },
{ name = "tzlocal" },
{ name = "uuid6" },
@@ -4498,7 +4494,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.30.4"
version = "1.31.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4594,7 +4590,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -5530,52 +5526,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
name = "stackit-core"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" },
]
[[package]]
name = "stackit-iaas"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "stackit-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" },
]
[[package]]
name = "stackit-resourcemanager"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "stackit-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
]
[[package]]
name = "statsd"
version = "4.0.1"
@@ -20,7 +20,8 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
_Commands_:
```bash
<CodeGroup>
```bash macOS/Linux
VERSION=$(curl -s https://api.github.com/repos/prowler-cloud/prowler/releases/latest | jq -r .tag_name)
curl -sLO "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/${VERSION}/docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
@@ -28,6 +29,15 @@ Refer to the [Prowler App Tutorial](/user-guide/tutorials/prowler-app) for detai
docker compose up -d
```
```powershell Windows PowerShell
$VERSION = (Invoke-RestMethod -Uri "https://api.github.com/repos/prowler-cloud/prowler/releases/latest").tag_name
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/docker-compose.yml" -OutFile "docker-compose.yml"
# Environment variables can be customized in the .env file. Using default values in production environments is not recommended.
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/prowler-cloud/prowler/refs/tags/$VERSION/.env" -OutFile ".env"
docker compose up -d
```
</CodeGroup>
<Callout icon="lock" iconType="regular" color="#e74c3c">
For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
</Callout>
@@ -118,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.28.0"
PROWLER_API_VERSION="5.28.0"
PROWLER_UI_VERSION="5.29.0"
PROWLER_API_VERSION="5.29.0"
```
<Note>
@@ -47,11 +47,7 @@ Follow these steps to remove a user of your account:
1. Navigate to **Users** from the side menu.
2. Click the delete button of your current user.
> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
+14
View File
@@ -17,6 +17,20 @@ enforce key strength in our own auth code, so this advisory does not apply.
Re-evaluate when a non-disputed advisory or upstream fix lands.
"""
[[IgnoredVulns]]
id = "GHSA-897w-fcg9-f6xj"
ignoreUntil = 2026-09-01T00:00:00Z
reason = """
Temporary suppression for api/uv.lock only. The SDK (root pyproject.toml) is
already bumped to dulwich==1.2.6, which fixes this advisory (patched in 1.2.5).
api/uv.lock resolves dulwich transitively through `prowler @ git+...@master`,
which still pins dulwich==0.23.0 at the locked commit, so api cannot upgrade
until the SDK fix lands on master and api/uv.lock is regenerated against the
new commit. The advisory is also Windows-only (arbitrary file write via
NTFS-hostile tree entries); the API runs in Linux containers. Remove this entry
once api/uv.lock is refreshed and no longer resolves dulwich 0.23.0.
"""
[[IgnoredVulns]]
id = "PYSEC-2026-89"
ignoreUntil = 2026-08-20T00:00:00Z
-19
View File
@@ -2,25 +2,6 @@
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
- OCSF output writer now re-raises I/O errors (e.g. `ENOSPC`) instead of logging them per finding and leaving a truncated file [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
---
## [5.29.0] (Prowler v5.29.0)
### 🚀 Added
+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.4"
prowler_version = "5.30.0"
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"
+1 -15
View File
@@ -229,9 +229,7 @@ class MarkdownToADFConverter:
return node
def _paragraph_with_text(self, text: str) -> Dict:
# 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}
return {"type": "paragraph", "content": [self._create_text_node(text, None)]}
@staticmethod
def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None:
@@ -1120,18 +1118,6 @@ 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",
-8
View File
@@ -227,10 +227,6 @@ class OCSF(Output):
json_output = finding.json(exclude_none=True, indent=4)
self._file_descriptor.write(json_output)
self._file_descriptor.write(",")
except OSError:
# I/O errors (e.g. ENOSPC) are not recoverable per finding:
# fail fast instead of logging once per finding.
raise
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -243,10 +239,6 @@ class OCSF(Output):
self._file_descriptor.truncate()
self._file_descriptor.write("]")
self._file_descriptor.close()
except OSError:
# Propagate unrecoverable I/O errors (e.g. ENOSPC) so the caller can
# fail fast instead of producing a corrupt output file.
raise
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -37,7 +37,6 @@ class IAM(GCPService):
display_name=account.get("displayName", ""),
project_id=project_id,
uniqueId=account.get("uniqueId", ""),
disabled=account.get("disabled", False),
)
)
@@ -103,7 +102,6 @@ class ServiceAccount(BaseModel):
keys: list[Key] = []
project_id: str
uniqueId: str
disabled: bool = False
class AccessApproval(GCPService):
@@ -19,12 +19,7 @@ class iam_service_account_unused(Check):
resource_id=account.email,
location=iam_client.region,
)
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:
if 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,8 +1,5 @@
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,
)
@@ -13,10 +10,12 @@ 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 metric_filter in metric.filter:
if (
'protoPayload.methodName="SetIamPolicy" AND protoPayload.serviceData.policyDelta.auditConfigDeltas:*'
in metric.filter
):
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -34,11 +33,6 @@ 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(
@@ -52,12 +46,8 @@ class logging_log_metric_filter_and_alert_for_audit_configuration_changes_enable
else "GCP 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}."
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,8 +1,5 @@
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,
)
@@ -11,10 +8,12 @@ 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 metric_filter in metric.filter:
if (
'resource.type="gcs_bucket" AND protoPayload.methodName="storage.setIamPermissions"'
in metric.filter
):
metric_name = getattr(metric, "name", None) or "unknown"
report = Check_Report_GCP(
metadata=self.metadata(),
@@ -37,9 +36,6 @@ 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)
@@ -50,12 +46,8 @@ 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"),
)
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}."
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,8 +1,5 @@
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,
)
@@ -13,10 +10,9 @@ 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 metric_filter in metric.filter:
if 'protoPayload.serviceName="compute.googleapis.com"' in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -34,9 +30,6 @@ 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(
@@ -50,12 +43,8 @@ class logging_log_metric_filter_and_alert_for_compute_configuration_changes_enab
else "GCP 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}."
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,8 +1,5 @@
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,
)
@@ -11,10 +8,12 @@ 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 metric_filter in metric.filter:
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
):
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -32,9 +31,6 @@ 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(
@@ -48,12 +44,8 @@ class logging_log_metric_filter_and_alert_for_custom_role_changes_enabled(Check)
else "GCP 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}."
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,8 +1,5 @@
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,
)
@@ -11,10 +8,12 @@ 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 metric_filter in metric.filter:
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
):
metric_name = getattr(metric, "name", None) or "unknown"
report = Check_Report_GCP(
metadata=self.metadata(),
@@ -37,9 +36,6 @@ 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)
@@ -51,12 +47,8 @@ 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"),
)
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}."
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,8 +1,5 @@
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,
)
@@ -13,10 +10,9 @@ 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 metric_filter in metric.filter:
if 'protoPayload.methodName="cloudsql.instances.update"' in metric.filter:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -34,9 +30,6 @@ 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(
@@ -50,12 +43,8 @@ class logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes
else "GCP 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}."
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,8 +1,5 @@
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,
)
@@ -11,10 +8,12 @@ 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 metric_filter in metric.filter:
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
):
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -32,9 +31,6 @@ 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(
@@ -48,12 +44,8 @@ class logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled(
else "GCP 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}."
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,8 +1,5 @@
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,
)
@@ -11,10 +8,12 @@ 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 metric_filter in metric.filter:
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
):
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -32,9 +31,6 @@ 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(
@@ -48,12 +44,8 @@ class logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled(Check)
else "GCP 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}."
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,8 +1,5 @@
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,
)
@@ -11,10 +8,12 @@ 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 metric_filter in metric.filter:
if (
'resource.type="gce_route" AND (protoPayload.methodName:"compute.routes.delete" OR protoPayload.methodName:"compute.routes.insert")'
in metric.filter
):
report = Check_Report_GCP(
metadata=self.metadata(),
resource=metric,
@@ -32,9 +31,6 @@ 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(
@@ -48,12 +44,8 @@ class logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled(
else "GCP 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}."
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,7 +12,6 @@ class Logging(GCPService):
self.sinks = []
self.metrics = []
self._get_sinks()
self._get_org_sinks()
self._get_metrics()
def _get_sinks(self):
@@ -40,38 +39,6 @@ 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:
@@ -90,7 +57,6 @@ class Logging(GCPService):
type=metric["metricDescriptor"]["type"],
filter=metric["filter"],
project_id=project_id,
bucket_name=metric.get("bucketName", ""),
)
)
@@ -110,7 +76,6 @@ class Sink(BaseModel):
destination: str
filter: str
project_id: str
include_children: bool = False
class Metric(BaseModel):
@@ -118,59 +83,3 @@ 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,30 +5,26 @@ 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" and not sink.include_children:
if sink.filter == "all":
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:
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:
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:
sink = projects_with_logging_sink[project]
sink_name = getattr(sink, "name", None) or "unknown"
report = Check_Report_GCP(
@@ -44,31 +40,4 @@ 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
+2 -2
View File
@@ -73,7 +73,7 @@ dependencies = [
"dash-bootstrap-components==2.0.3",
"defusedxml==0.7.1",
"detect-secrets==1.5.0",
"dulwich==0.23.0",
"dulwich==1.2.6",
"google-api-python-client==2.163.0",
"google-auth-httplib2==0.2.0",
"jsonschema==4.23.0",
@@ -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.4"
version = "5.30.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
-83
View File
@@ -1004,89 +1004,6 @@ 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"]
-32
View File
@@ -2,10 +2,8 @@ import json
from datetime import datetime, timezone
from io import StringIO
from typing import Optional
from unittest.mock import MagicMock
from uuid import UUID
import pytest
import requests
from freezegun import freeze_time
from mock import patch
@@ -302,36 +300,6 @@ class TestOCSF:
def test_batch_write_data_to_file_without_findings(self):
assert not OCSF([])._file_descriptor
def test_batch_write_data_to_file_propagates_oserror(self):
"""An I/O error (e.g. ENOSPC) while writing a finding must propagate
instead of being swallowed, so the caller can fail fast."""
findings = [
generate_finding_output(
status="FAIL",
severity="low",
muted=False,
region=AWS_REGION_EU_WEST_1,
timestamp=datetime.now(),
resource_details="resource_details",
resource_name="resource_name",
resource_uid="resource-id",
status_extended="status extended",
)
]
output = OCSF(findings)
mock_file = MagicMock()
mock_file.closed = False
# Non-zero so the "[" prelude is skipped and the failure happens on the
# per-finding write, the exact path that hit ENOSPC in production.
mock_file.tell.return_value = 1
mock_file.write.side_effect = OSError(28, "No space left on device")
output._file_descriptor = mock_file
with pytest.raises(OSError) as excinfo:
output.batch_write_data_to_file()
assert excinfo.value.errno == 28
def test_finding_output_cloud_pass_low_muted(self):
finding_output = generate_finding_output(
status="PASS",
@@ -179,60 +179,3 @@ 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,176 +259,3 @@ 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,173 +397,3 @@ 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,173 +346,3 @@ 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,173 +259,3 @@ 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,173 +392,3 @@ 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,173 +259,3 @@ 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,173 +259,3 @@ 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,173 +259,3 @@ 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,173 +259,3 @@ 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 MagicMock, patch
from unittest.mock import patch
from prowler.providers.gcp.services.logging.logging_service import Logging
from tests.providers.gcp.gcp_fixtures import (
@@ -66,237 +66,3 @@ 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 GCPOrganization, GCPProject
from prowler.providers.gcp.models import GCPProject
from tests.providers.gcp.gcp_fixtures import (
GCP_EU1_LOCATION,
GCP_PROJECT_ID,
@@ -268,7 +268,6 @@ 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
@@ -312,10 +311,9 @@ class Test_logging_sink_created:
)
# Create a MagicMock sink object without name attribute
sink = MagicMock(spec=["filter", "project_id", "include_children"])
sink = MagicMock(spec=["filter", "project_id"])
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
@@ -338,175 +336,3 @@ 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
-25
View File
@@ -2,31 +2,6 @@
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
- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
### 🐞 Fixed
- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447)
### 🔐 Security
- Vitest toolchain upgraded `4.0.18``4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
---
## [1.29.0] (Prowler v5.29.0)
### 🚀 Added
@@ -1,4 +1,4 @@
import { render, screen, within } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
@@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
);
},
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-testid="trigger">{children}</div>
<div>{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -220,45 +220,4 @@ describe("AccountsSelector", () => {
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
});
it("shows the provider icon next to the name in the trigger for a single selection", async () => {
render(
<AccountsSelector
providers={providers}
onBatchChange={vi.fn()}
selectedValues={["provider-1"]}
/>,
);
const trigger = screen.getByTestId("trigger");
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
expect(within(trigger).getByText("Production AWS")).toBeInTheDocument();
});
it("renders one icon per selected account without deduping by provider type", async () => {
const secondAws = {
...providers[0],
id: "provider-2",
attributes: {
...providers[0].attributes,
uid: "999999999999",
alias: "Staging AWS",
},
};
render(
<AccountsSelector
providers={[providers[0], secondAws]}
onBatchChange={vi.fn()}
selectedValues={["provider-1", "provider-2"]}
/>,
);
const trigger = screen.getByTestId("trigger");
// Two AWS accounts -> two AWS icons in the trigger (no dedupe).
expect(await within(trigger).findAllByText("AWS")).toHaveLength(2);
expect(
within(trigger).getByText("2 Providers selected"),
).toBeInTheDocument();
});
});
@@ -1,12 +1,26 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { ReactNode, useState } from "react";
import {
ProviderTypeIcon,
ProviderTypeIconStack,
} from "@/components/icons/providers-badge/provider-type-icon";
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
import { Badge } from "@/components/shadcn";
import {
MultiSelect,
@@ -31,6 +45,25 @@ const ACCOUNT_SELECTOR_FILTER = {
type AccountSelectorFilter =
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
azure: <AzureProviderBadge width={18} height={18} />,
gcp: <GCPProviderBadge width={18} height={18} />,
kubernetes: <KS8ProviderBadge width={18} height={18} />,
m365: <M365ProviderBadge width={18} height={18} />,
github: <GitHubProviderBadge width={18} height={18} />,
googleworkspace: <GoogleWorkspaceProviderBadge width={18} height={18} />,
iac: <IacProviderBadge width={18} height={18} />,
image: <ImageProviderBadge width={18} height={18} />,
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
openstack: <OpenStackProviderBadge width={18} height={18} />,
vercel: <VercelProviderBadge width={18} height={18} />,
okta: <OktaProviderBadge width={18} height={18} />,
};
/** Common props shared by both batch and instant modes. */
interface AccountsSelectorBaseProps {
providers: ProviderProps[];
@@ -125,36 +158,10 @@ export function AccountsSelector({
if (selectedIds.length === 1) {
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
return (
<span className="flex min-w-0 items-center gap-2">
{p && (
<span aria-hidden="true">
<ProviderTypeIcon type={p.attributes.provider} />
</span>
)}
<span className="truncate">{name}</span>
</span>
);
return <span className="truncate">{name}</span>;
}
// One icon per selected account (no dedupe): two accounts of the same
// provider show two icons, disambiguated by the UID tooltip on hover.
const items = selectedIds
.map((selectedId) =>
providers.find((pr) => getProviderValue(pr) === selectedId),
)
.filter((p): p is ProviderProps => Boolean(p))
.map((p) => ({
key: p.id,
type: p.attributes.provider as ProviderType,
tooltip: p.attributes.uid,
}));
return (
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIconStack items={items} />
<span className="truncate">
{selectedIds.length} Providers selected
</span>
</span>
<span className="truncate">{selectedIds.length} Providers selected</span>
);
};
@@ -201,6 +208,7 @@ export function AccountsSelector({
const isDisabled = disabledValuesSet.has(value);
const displayName = p.attributes.alias || p.attributes.uid;
const providerType = p.attributes.provider as ProviderType;
const icon = PROVIDER_ICON[providerType];
const searchKeywords = [
displayName,
p.attributes.alias,
@@ -220,9 +228,7 @@ export function AccountsSelector({
if (closeOnSelect) setSelectorOpen(false);
}}
>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</span>
<span aria-hidden="true">{icon}</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{displayName}</span>
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
@@ -1,4 +1,4 @@
import { render, screen, within } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ProviderTypeSelector } from "./provider-type-selector";
@@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
<div>{children}</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div data-testid="trigger">{children}</div>
<div>{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -145,26 +145,4 @@ describe("ProviderTypeSelector", () => {
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByText("All selected")).toBeInTheDocument();
});
it("shows one icon per selected type and a count in the trigger", async () => {
const azure = {
...providers[0],
id: "provider-2",
attributes: { ...providers[0].attributes, provider: "azure" as const },
};
render(
<ProviderTypeSelector
providers={[providers[0], azure]}
onBatchChange={vi.fn()}
selectedValues={["aws", "azure"]}
/>,
);
const trigger = screen.getByTestId("trigger");
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
expect(
within(trigger).getByText("2 Provider Types selected"),
).toBeInTheDocument();
});
});
@@ -1,12 +1,8 @@
"use client";
import { useSearchParams } from "next/navigation";
import { type ComponentType, lazy, Suspense } from "react";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
ProviderTypeIconStack,
} from "@/components/icons/providers-badge/provider-type-icon";
import {
MultiSelect,
MultiSelectContent,
@@ -18,6 +14,163 @@ import {
import { useUrlFilters } from "@/hooks/use-url-filters";
import { type ProviderProps, ProviderType } from "@/types/providers";
const AWSProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AWSProviderBadge,
})),
);
const AzureProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AzureProviderBadge,
})),
);
const GCPProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GCPProviderBadge,
})),
);
const KS8ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.KS8ProviderBadge,
})),
);
const M365ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.M365ProviderBadge,
})),
);
const GitHubProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GitHubProviderBadge,
})),
);
const IacProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.IacProviderBadge,
})),
);
const ImageProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.ImageProviderBadge,
})),
);
const OracleCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OracleCloudProviderBadge,
})),
);
const MongoDBAtlasProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.MongoDBAtlasProviderBadge,
})),
);
const AlibabaCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AlibabaCloudProviderBadge,
})),
);
const CloudflareProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.CloudflareProviderBadge,
})),
);
const OpenStackProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OpenStackProviderBadge,
})),
);
const GoogleWorkspaceProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GoogleWorkspaceProviderBadge,
})),
);
const VercelProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.VercelProviderBadge,
})),
);
const OktaProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OktaProviderBadge,
})),
);
type IconProps = { width: number; height: number };
const IconPlaceholder = ({ width, height }: IconProps) => (
<div style={{ width, height }} />
);
const PROVIDER_DATA: Record<
ProviderType,
{ label: string; icon: ComponentType<IconProps> }
> = {
aws: {
label: "Amazon Web Services",
icon: AWSProviderBadge,
},
azure: {
label: "Microsoft Azure",
icon: AzureProviderBadge,
},
gcp: {
label: "Google Cloud Platform",
icon: GCPProviderBadge,
},
kubernetes: {
label: "Kubernetes",
icon: KS8ProviderBadge,
},
m365: {
label: "Microsoft 365",
icon: M365ProviderBadge,
},
github: {
label: "GitHub",
icon: GitHubProviderBadge,
},
googleworkspace: {
label: "Google Workspace",
icon: GoogleWorkspaceProviderBadge,
},
iac: {
label: "Infrastructure as Code",
icon: IacProviderBadge,
},
image: {
label: "Container Registry",
icon: ImageProviderBadge,
},
oraclecloud: {
label: "Oracle Cloud Infrastructure",
icon: OracleCloudProviderBadge,
},
mongodbatlas: {
label: "MongoDB Atlas",
icon: MongoDBAtlasProviderBadge,
},
alibabacloud: {
label: "Alibaba Cloud",
icon: AlibabaCloudProviderBadge,
},
cloudflare: {
label: "Cloudflare",
icon: CloudflareProviderBadge,
},
openstack: {
label: "OpenStack",
icon: OpenStackProviderBadge,
},
vercel: {
label: "Vercel",
icon: VercelProviderBadge,
},
okta: {
label: "Okta",
icon: OktaProviderBadge,
},
};
/** Common props shared by both batch and instant modes. */
interface ProviderTypeSelectorBaseProps {
providers: ProviderProps[];
@@ -94,38 +247,34 @@ export const ProviderTypeSelector = ({
.map((p) => p.attributes.provider),
),
)
.filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
.filter((type): type is ProviderType => type in PROVIDER_DATA)
.sort((a, b) =>
PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label),
PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
);
const renderIcon = (providerType: ProviderType) => {
const IconComponent = PROVIDER_DATA[providerType].icon;
return (
<Suspense fallback={<IconPlaceholder width={24} height={24} />}>
<IconComponent width={24} height={24} />
</Suspense>
);
};
const selectedLabel = () => {
if (selectedTypes.length === 0) return null;
if (selectedTypes.length === 1) {
const providerType = selectedTypes[0] as ProviderType;
return (
<span className="flex min-w-0 items-center gap-2">
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</span>
<span className="truncate">
{PROVIDER_TYPE_DATA[providerType].label}
</span>
{renderIcon(providerType)}
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
</span>
);
}
return (
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIconStack
items={(selectedTypes as ProviderType[]).map((type) => ({
key: type,
type,
tooltip: PROVIDER_TYPE_DATA[type].label,
}))}
/>
<span className="min-w-0 truncate">
{selectedTypes.length} Provider Types selected
</span>
<span className="min-w-0 truncate">
{selectedTypes.length} Provider Types selected
</span>
);
};
@@ -180,17 +329,12 @@ export const ProviderTypeSelector = ({
<MultiSelectItem
key={providerType}
value={providerType}
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
keywords={[
providerType,
PROVIDER_TYPE_DATA[providerType].label,
]}
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
badgeLabel={PROVIDER_DATA[providerType].label}
keywords={[providerType, PROVIDER_DATA[providerType].label]}
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} size={24} />
</span>
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
<span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span>
</MultiSelectItem>
))}
</>
-3
View File
@@ -109,9 +109,6 @@ const SSRDataTable = async ({
roles,
canBeExpelled,
currentTenantId: canBeExpelled ? currentTenantId : undefined,
// Users may only delete their own account; gate the delete action so the
// UI matches the backend rule and never offers an action that would fail.
isCurrentUser: user.id === currentUserId,
};
});
@@ -1,45 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderType } from "@/types/providers";
import { ProviderIconCell } from "./provider-icon-cell";
// Render the lazy provider badges as plain text so we can assert on them. The
// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose.
vi.mock("@/components/icons/providers-badge", () => ({
AWSProviderBadge: () => <span>AWS</span>,
AzureProviderBadge: () => <span>Azure</span>,
GCPProviderBadge: () => <span>GCP</span>,
KS8ProviderBadge: () => <span>Kubernetes</span>,
M365ProviderBadge: () => <span>M365</span>,
GitHubProviderBadge: () => <span>GitHub</span>,
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
IacProviderBadge: () => <span>IaC</span>,
ImageProviderBadge: () => <span>Image</span>,
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
CloudflareProviderBadge: () => <span>Cloudflare</span>,
OpenStackProviderBadge: () => <span>OpenStack</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
describe("ProviderIconCell", () => {
it("renders the shared provider-type icon for a known provider", async () => {
render(<ProviderIconCell provider="aws" />);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
it("renders a '?' placeholder for a provider type missing from the map", () => {
render(
<ProviderIconCell
provider={"future-provider" as unknown as ProviderType}
/>,
);
expect(screen.getByText("?")).toBeInTheDocument();
});
});
@@ -1,10 +1,43 @@
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
import { cn } from "@/lib/utils";
import { ProviderType } from "@/types";
export const PROVIDER_ICONS = {
aws: AWSProviderBadge,
azure: AzureProviderBadge,
gcp: GCPProviderBadge,
kubernetes: KS8ProviderBadge,
m365: M365ProviderBadge,
github: GitHubProviderBadge,
googleworkspace: GoogleWorkspaceProviderBadge,
iac: IacProviderBadge,
image: ImageProviderBadge,
oraclecloud: OracleCloudProviderBadge,
mongodbatlas: MongoDBAtlasProviderBadge,
alibabacloud: AlibabaCloudProviderBadge,
cloudflare: CloudflareProviderBadge,
openstack: OpenStackProviderBadge,
vercel: VercelProviderBadge,
okta: OktaProviderBadge,
} as const;
interface ProviderIconCellProps {
provider: ProviderType;
size?: number;
@@ -16,9 +49,9 @@ export const ProviderIconCell = ({
size = 26,
className = "size-8 rounded-md bg-white",
}: ProviderIconCellProps) => {
// Unknown provider types (present in the data but missing from the shared
// PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon.
if (!(provider in PROVIDER_TYPE_DATA)) {
const IconComponent = PROVIDER_ICONS[provider];
if (!IconComponent) {
return (
<div className={cn("flex items-center justify-center", className)}>
<span className="text-text-neutral-secondary text-xs">?</span>
@@ -33,7 +66,7 @@ export const ProviderIconCell = ({
className,
)}
>
<ProviderTypeIcon type={provider} size={size} />
<IconComponent width={size} height={size} />
</div>
);
};
@@ -1,126 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ProviderType } from "@/types/providers";
import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon";
// A provider type the API may return but this UI build does not know about.
const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType;
// Render the lazy provider badges as plain text so we can assert on them.
vi.mock("@/components/icons/providers-badge", () => ({
AWSProviderBadge: () => <span>AWS</span>,
AzureProviderBadge: () => <span>Azure</span>,
GCPProviderBadge: () => <span>GCP</span>,
KS8ProviderBadge: () => <span>Kubernetes</span>,
M365ProviderBadge: () => <span>M365</span>,
GitHubProviderBadge: () => <span>GitHub</span>,
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
IacProviderBadge: () => <span>IaC</span>,
ImageProviderBadge: () => <span>Image</span>,
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
CloudflareProviderBadge: () => <span>Cloudflare</span>,
OpenStackProviderBadge: () => <span>OpenStack</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
// Render the tooltip pieces inline so the hover content is queryable in jsdom.
vi.mock("@/components/shadcn", () => ({
Badge: ({ children }: { children: React.ReactNode }) => (
<span data-testid="badge">{children}</span>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<span data-testid="tooltip">{children}</span>
),
}));
describe("ProviderTypeIcon", () => {
it("renders the badge for the given provider type", async () => {
render(<ProviderTypeIcon type="aws" />);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
it("renders a sized placeholder instead of crashing for an unknown type", () => {
// Regression guard for #9991: an unknown provider type must not throw.
const { container } = render(
<ProviderTypeIcon type={UNKNOWN_TYPE} size={24} />,
);
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
expect(container.querySelector("div")).toHaveStyle({
width: "24px",
height: "24px",
});
});
});
describe("ProviderTypeIconStack", () => {
it("renders one icon per item without deduping by type", async () => {
render(
<ProviderTypeIconStack
items={[
{ key: "a", type: "aws", tooltip: "111" },
{ key: "b", type: "aws", tooltip: "222" },
]}
/>,
);
// Two AWS accounts -> two AWS icons (no dedupe).
expect(await screen.findAllByText("AWS")).toHaveLength(2);
});
it("shows each item's tooltip text on the icon", async () => {
render(
<ProviderTypeIconStack
items={[{ key: "a", type: "aws", tooltip: "account-uid-123" }]}
/>,
);
expect(await screen.findByTestId("tooltip")).toHaveTextContent(
"account-uid-123",
);
});
it("collapses items beyond `max` into a +N badge", async () => {
render(
<ProviderTypeIconStack
max={3}
items={[
{ key: "a", type: "aws", tooltip: "1" },
{ key: "b", type: "azure", tooltip: "2" },
{ key: "c", type: "gcp", tooltip: "3" },
{ key: "d", type: "github", tooltip: "4" },
{ key: "e", type: "okta", tooltip: "5" },
]}
/>,
);
expect(await screen.findByTestId("badge")).toHaveTextContent("+2");
// First icon is shown; items sliced beyond `max` never reach the DOM.
expect(await screen.findByText("AWS")).toBeInTheDocument();
expect(screen.queryByText("Okta")).not.toBeInTheDocument();
});
it("renders known icons and skips unknown types without crashing", async () => {
// Regression guard for #9991: an unknown type in the stack must not throw.
render(
<ProviderTypeIconStack
items={[
{ key: "a", type: "aws", tooltip: "111" },
{ key: "b", type: UNKNOWN_TYPE, tooltip: "222" },
]}
/>,
);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
});
@@ -1,250 +0,0 @@
"use client";
import { type ComponentType, lazy, Suspense } from "react";
import {
Badge,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { cn } from "@/lib/utils";
import type { ProviderType } from "@/types/providers";
type IconProps = { width: number; height: number };
const IconPlaceholder = ({ width, height }: IconProps) => (
<div style={{ width, height }} />
);
// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred
// chunk instead of being eagerly bundled wherever a selector is imported.
const AWSProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AWSProviderBadge,
})),
);
const AzureProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AzureProviderBadge,
})),
);
const GCPProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GCPProviderBadge,
})),
);
const KS8ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.KS8ProviderBadge,
})),
);
const M365ProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.M365ProviderBadge,
})),
);
const GitHubProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GitHubProviderBadge,
})),
);
const GoogleWorkspaceProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.GoogleWorkspaceProviderBadge,
})),
);
const IacProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.IacProviderBadge,
})),
);
const ImageProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.ImageProviderBadge,
})),
);
const OracleCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OracleCloudProviderBadge,
})),
);
const MongoDBAtlasProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.MongoDBAtlasProviderBadge,
})),
);
const AlibabaCloudProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.AlibabaCloudProviderBadge,
})),
);
const CloudflareProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.CloudflareProviderBadge,
})),
);
const OpenStackProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OpenStackProviderBadge,
})),
);
const VercelProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.VercelProviderBadge,
})),
);
const OktaProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OktaProviderBadge,
})),
);
/**
* Single source of truth mapping each provider type to its human-readable
* label and (lazy) badge component. Shared by the account and provider-type
* selectors so both stay in sync on labels, icons, and sizing.
*/
export const PROVIDER_TYPE_DATA: Record<
ProviderType,
{ label: string; icon: ComponentType<IconProps> }
> = {
aws: { label: "Amazon Web Services", icon: AWSProviderBadge },
azure: { label: "Microsoft Azure", icon: AzureProviderBadge },
gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge },
kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge },
m365: { label: "Microsoft 365", icon: M365ProviderBadge },
github: { label: "GitHub", icon: GitHubProviderBadge },
googleworkspace: {
label: "Google Workspace",
icon: GoogleWorkspaceProviderBadge,
},
iac: { label: "Infrastructure as Code", icon: IacProviderBadge },
image: { label: "Container Registry", icon: ImageProviderBadge },
oraclecloud: {
label: "Oracle Cloud Infrastructure",
icon: OracleCloudProviderBadge,
},
mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge },
alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge },
cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge },
openstack: { label: "OpenStack", icon: OpenStackProviderBadge },
vercel: { label: "Vercel", icon: VercelProviderBadge },
okta: { label: "Okta", icon: OktaProviderBadge },
};
interface ProviderTypeIconProps {
type: ProviderType;
size?: number;
}
/**
* Renders a single provider-type badge with a sized placeholder fallback.
*
* Falls back to the placeholder for provider types missing from
* `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI
* build does not). The `type` is statically typed as `ProviderType`, so this
* only guards the runtime case see #9991, which fixed the same crash class.
*/
export const ProviderTypeIcon = ({
type,
size = 18,
}: ProviderTypeIconProps) => {
const data = PROVIDER_TYPE_DATA[type];
if (!data) return <IconPlaceholder width={size} height={size} />;
const Icon = data.icon;
return (
<Suspense fallback={<IconPlaceholder width={size} height={size} />}>
<Icon width={size} height={size} />
</Suspense>
);
};
export interface ProviderTypeIconStackItem {
/** Stable React key (account id for accounts, provider type for types). */
key: string;
type: ProviderType;
/** Text shown on hover to disambiguate the icon (e.g. an account UID). */
tooltip?: string;
}
interface ProviderTypeIconStackProps {
items: ProviderTypeIconStackItem[];
max?: number;
size?: number;
className?: string;
}
/**
* Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a
* Radix portal, so the tooltip is not clipped by the selector trigger and we do
* not need to portal it ourselves. `delayDuration` is set on the tooltip itself
* because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider`
* (delay 0), which would otherwise override an ancestor provider's delay.
*/
const IconWithTooltip = ({
item,
size,
}: {
item: ProviderTypeIconStackItem;
size: number;
}) => {
const icon = (
<span className="inline-flex shrink-0">
<ProviderTypeIcon type={item.type} size={size} />
</span>
);
if (!item.tooltip) return icon;
return (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{icon}</TooltipTrigger>
<TooltipContent side="top">{item.tooltip}</TooltipContent>
</Tooltip>
);
};
/**
* Renders up to `max` provider-type icons followed by a `+N` badge for the
* remainder. Each icon shows its `tooltip` on hover. Items are rendered as
* passed (one per selection) callers decide whether to dedupe.
*/
export const ProviderTypeIconStack = ({
items,
max = 3,
size = 18,
className,
}: ProviderTypeIconStackProps) => {
const visible = items.slice(0, max);
const overflow = items.slice(max);
const overflowLabel = overflow
.map((item) => item.tooltip)
.filter(Boolean)
.join(", ");
return (
<span className={cn("flex shrink-0 items-center gap-1", className)}>
<span className="flex items-center gap-1">
{visible.map((item) => (
<IconWithTooltip key={item.key} item={item} size={size} />
))}
</span>
{overflow.length > 0 && (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>
<Badge variant="tag" className="px-1.5 py-0.5 text-xs font-medium">
+{overflow.length}
</Badge>
</TooltipTrigger>
{overflowLabel && (
<TooltipContent side="top" className="max-w-xs">
{overflowLabel}
</TooltipContent>
)}
</Tooltip>
)}
</span>
);
};
@@ -8,10 +8,7 @@ import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
@@ -282,14 +279,11 @@ export const S3IntegrationForm = ({
// Show configuration step (step 0 or editing configuration)
if (isEditingConfig || currentStep === 0) {
const providerOptions = providers.map((provider) => {
const providerType = provider.attributes.provider;
const Icon = PROVIDER_ICONS[provider.attributes.provider];
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
description: provider.attributes.connection.connected
? "Connected"
: "Disconnected",
@@ -10,10 +10,7 @@ import { useEffect, useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
import { useToast } from "@/components/ui";
@@ -124,14 +121,11 @@ export const SecurityHubIntegrationForm = ({
? "Connected"
: "Disconnected";
const providerType = provider.attributes.provider;
const Icon = PROVIDER_ICONS[provider.attributes.provider];
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
description: isDisabled
? `${connectionLabel} (Already in use)`
: connectionLabel,
@@ -1,92 +0,0 @@
"use client";
import Link from "next/link";
import { InfoIcon } from "@/components/icons/Icons";
import { Button, Card, CardContent } from "@/components/shadcn";
import { cn } from "@/lib/utils";
const NO_PROVIDERS_ADDED_ACTION = {
BUTTON: "button",
LINK: "link",
} as const;
interface NoProvidersAddedBaseProps {
containerClassName?: string;
}
interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps {
action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON;
onOpenWizard: () => void;
href?: never;
}
interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps {
action: typeof NO_PROVIDERS_ADDED_ACTION.LINK;
href: string;
onOpenWizard?: never;
}
type NoProvidersAddedProps =
| NoProvidersAddedButtonProps
| NoProvidersAddedLinkProps;
const renderCta = (props: NoProvidersAddedProps) => {
if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) {
return (
<Button
asChild
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
>
<Link href={props.href}>Get Started</Link>
</Button>
);
}
return (
<Button
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={props.onOpenWizard}
>
Get Started
</Button>
);
};
export const NoProvidersAdded = (props: NoProvidersAddedProps) => {
return (
<div
role="region"
aria-labelledby="no-providers-added-title"
className={cn(
"flex min-h-[calc(100dvh-10rem)] items-center justify-center",
props.containerClassName,
)}
>
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2
id="no-providers-added-title"
className="text-2xl font-bold text-gray-800 dark:text-white"
>
No Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No providers have been configured. Start by setting up a provider.
</p>
</div>
{renderCta(props)}
</CardContent>
</Card>
</div>
);
};
@@ -1,36 +1,11 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
refreshMock: vi.fn(),
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/providers",
useRouter: () => ({
refresh: refreshMock,
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/providers/table", () => ({
SkeletonTableProviders: () => <div data-testid="providers-skeleton" />,
}));
vi.mock("@/components/providers/add-provider-button", () => ({
AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => (
<button type="button" onClick={onOpenWizard}>
Add Provider
</button>
),
AddProviderButton: () => <button type="button">Add provider</button>,
}));
vi.mock("@/components/providers/muted-findings-config-button", () => ({
@@ -40,12 +15,7 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
}));
vi.mock("@/components/providers/providers-filters", () => ({
ProvidersFilters: ({ actions }: { actions: ReactNode }) => (
<div data-testid="providers-filters">
Filters
{actions}
</div>
),
ProvidersFilters: () => <div data-testid="providers-filters">Filters</div>,
}));
vi.mock("@/components/providers/providers-accounts-table", () => ({
@@ -53,21 +23,7 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div role="dialog">
Provider wizard
<button type="button" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
}));
import { ProvidersAccountsView } from "./providers-accounts-view";
@@ -80,55 +36,8 @@ const metadata: MetaDataProps = {
version: "latest",
};
const disconnectedProviders: ProviderProps[] = [
{
id: "provider-1",
type: "providers",
attributes: {
provider: "aws",
uid: "123456789012",
alias: "Production",
status: "completed",
resources: 0,
connection: {
connected: false,
last_checked_at: "2026-04-13T00:00:00Z",
},
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: "2026-04-13T00:00:00Z",
updated_at: "2026-04-13T00:00:00Z",
created_by: {
object: "user",
id: "user-1",
},
},
relationships: {
secret: {
data: null,
},
provider_groups: {
meta: {
count: 0,
},
data: [],
},
},
},
];
describe("ProvidersAccountsView", () => {
afterEach(() => {
vi.restoreAllMocks();
searchParamsValue.current = "";
window.history.replaceState({}, "", "/");
});
it("shows a full page empty state without filters or table when there are no providers", () => {
// Given/When
it("keeps the same vertical spacing between filters and table as other views", () => {
render(
<ProvidersAccountsView
isCloud={false}
@@ -139,170 +48,11 @@ describe("ProvidersAccountsView", () => {
/>,
);
// Then
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
expect(
screen.getByRole("region", { name: /no providers configured/i }),
).toHaveClass("min-h-[calc(100dvh-28rem)]");
expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument();
expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument();
});
it("opens the provider wizard from the no providers CTA", async () => {
// Given
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => {
// Given
searchParamsValue.current = "tab=connected&addProvider=true";
window.history.replaceState(
{},
"",
"/providers?tab=connected&addProvider=true",
);
// Spy only after the URL setup so we measure what the component does on mount.
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
expect(replaceStateSpy).not.toHaveBeenCalled();
});
it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => {
// Given
searchParamsValue.current = "tab=connected&addProvider=true";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /close/i }));
// Then
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
// The URL is cleaned via the History API (no RSC refetch). We must NOT
// refresh/replace here: re-running the /providers Server Component on close
// read as a full page reload. The provider-creation actions already
// revalidatePath("/providers"), so the table is fresh behind the modal.
expect(replaceStateSpy).toHaveBeenCalledWith(
null,
"",
"/providers?tab=connected",
);
expect(refreshMock).not.toHaveBeenCalled();
expect(replaceMock).not.toHaveBeenCalled();
});
it("does not touch the URL or refetch when a manually opened wizard closes", async () => {
// Given: no addProvider param in the URL, wizard opened via the CTA.
searchParamsValue.current = "";
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// When: open the wizard from the empty-state CTA, then close it.
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
await user.click(screen.getByRole("button", { name: /close/i }));
// Then: nothing to clean and no refresh — the creation actions own the
// data refresh via revalidatePath.
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(replaceStateSpy).not.toHaveBeenCalled();
expect(refreshMock).not.toHaveBeenCalled();
expect(replaceMock).not.toHaveBeenCalled();
});
it("keeps filters and table visible when providers are disconnected", () => {
// Given/When
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
/>,
);
// Then
expect(screen.getByTestId("providers-filters").parentElement).toHaveClass(
"flex",
"flex-col",
"gap-6",
);
expect(screen.getByTestId("providers-table")).toBeInTheDocument();
expect(
screen.queryByText("No Providers Configured"),
).not.toBeInTheDocument();
});
it("opens the provider wizard from the normal Add Provider button", async () => {
// Given
const user = userEvent.setup();
render(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /add provider/i }));
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
});
@@ -1,11 +1,9 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useState } from "react";
import { AddProviderButton } from "@/components/providers/add-provider-button";
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
import { ProvidersFilters } from "@/components/providers/providers-filters";
import { ProviderWizardModal } from "@/components/providers/wizard";
@@ -13,10 +11,6 @@ import type {
OrgWizardInitialData,
ProviderWizardInitialData,
} from "@/components/providers/wizard/types";
import {
ADD_PROVIDER_SEARCH_PARAM,
ADD_PROVIDER_SEARCH_VALUE,
} from "@/lib/providers-navigation";
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
import type { ProvidersTableRow } from "@/types/providers-table";
@@ -35,14 +29,7 @@ export function ProvidersAccountsView({
providers,
rows,
}: ProvidersAccountsViewProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const hasNoProviders = providers.length === 0;
const shouldOpenProviderWizardFromUrl =
searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE;
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(
() => shouldOpenProviderWizardFromUrl,
);
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const [providerWizardInitialData, setProviderWizardInitialData] = useState<
ProviderWizardInitialData | undefined
>(undefined);
@@ -65,64 +52,38 @@ export function ProvidersAccountsView({
const handleWizardOpenChange = (open: boolean) => {
setIsProviderWizardOpen(open);
if (open) return;
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(undefined);
// Only clean the one-shot ?addProvider intent from the URL bar, via the
// History API so it does NOT trigger an RSC refetch. We must not refresh
// here: the provider-creation actions (addProvider / addCredentialsProvider
// / checkConnectionProvider) already revalidatePath("/providers"), so the
// table updates behind the modal. A router.refresh()/replace() on close
// re-ran the whole /providers Server Component, which read as a full reload.
if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(ADD_PROVIDER_SEARCH_PARAM);
const query = params.toString();
window.history.replaceState(
null,
"",
query ? `${pathname}?${query}` : pathname,
);
if (!open) {
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(undefined);
}
};
return (
<>
{hasNoProviders ? (
<NoProvidersAdded
action="button"
containerClassName="min-h-[calc(100dvh-28rem)]"
onOpenWizard={() => openProviderWizard()}
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
/>
) : (
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
)}
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={handleWizardOpenChange}
initialData={providerWizardInitialData}
orgInitialData={orgWizardInitialData}
refreshOnClose={false}
/>
</>
);
@@ -50,10 +50,6 @@ interface UseProviderWizardControllerProps {
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
// When false, the caller skips the post-close router.refresh() and relies on
// the provider-creation actions' revalidatePath("/providers") to refresh the
// data. Defaults to true so standalone callers keep refreshing.
refreshOnClose?: boolean;
}
export function useProviderWizardController({
@@ -61,7 +57,6 @@ export function useProviderWizardController({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose = true,
}: UseProviderWizardControllerProps) {
const router = useRouter();
const initialProviderId = initialData?.providerId ?? null;
@@ -190,9 +185,7 @@ export function useProviderWizardController({
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
if (refreshOnClose) {
router.refresh();
}
router.refresh();
};
const handleDialogOpenChange = (nextOpen: boolean) => {
@@ -38,7 +38,6 @@ interface ProviderWizardModalProps {
onOpenChange: (open: boolean) => void;
initialData?: ProviderWizardInitialData;
orgInitialData?: OrgWizardInitialData;
refreshOnClose?: boolean;
}
export function ProviderWizardModal({
@@ -46,7 +45,6 @@ export function ProviderWizardModal({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose,
}: ProviderWizardModalProps) {
const {
backToProviderFlow,
@@ -74,7 +72,6 @@ export function ProviderWizardModal({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose,
});
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
const { containerRef, sentinelRef, showScrollHint } = useScrollHint({
@@ -0,0 +1,38 @@
"use client";
import { Button, Card, CardContent } from "@/components/shadcn";
import { InfoIcon } from "../icons/Icons";
interface NoProvidersAddedProps {
onOpenWizard: () => void;
}
export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => (
<div className="flex min-h-screen items-center justify-center">
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
No Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No providers have been configured. Start by setting up a provider.
</p>
</div>
<Button
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={onOpenWizard}
>
Get Started
</Button>
</CardContent>
</Card>
</div>
);
@@ -1,43 +1,73 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: ({ open }: { open: boolean }) =>
open ? <div role="dialog">Provider wizard</div> : null,
}));
vi.mock("./no-providers-connected", () => ({
NoProvidersConnected: () => <div>No Connected Providers</div>,
}));
describe("ScansProvidersEmptyState", () => {
it("shows the add provider message with a providers page CTA", () => {
// Given/When
render(<ScansProvidersEmptyState thereIsNoProviders />);
// Then
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
const cta = screen.getByRole("link", {
name: /open add provider modal/i,
});
expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF);
expect(cta.tagName).toBe("A");
afterEach(() => {
vi.clearAllMocks();
searchParamsValue.current = "";
});
it("does not render the provider wizard in Scans", () => {
// Given/When
it("shows the add provider message and opens the provider wizard", async () => {
const user = userEvent.setup();
render(<ScansProvidersEmptyState thereIsNoProviders />);
// Then
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("clears the launch scan URL intent before opening the provider wizard", async () => {
// Given
searchParamsValue.current = "tab=completed&launchScan=true";
const user = userEvent.setup();
render(<ScansProvidersEmptyState thereIsNoProviders />);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
scroll: false,
});
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("shows the no connected providers message", () => {
// Given/When
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
// Then
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
@@ -1,6 +1,12 @@
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
import { NoProvidersAdded } from "./no-providers-added";
import { NoProvidersConnected } from "./no-providers-connected";
interface ScansProvidersEmptyStateProps {
@@ -10,13 +16,35 @@ interface ScansProvidersEmptyStateProps {
export function ScansProvidersEmptyState({
thereIsNoProviders,
}: ScansProvidersEmptyStateProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const openProviderWizard = () => {
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
}
setIsProviderWizardOpen(true);
};
return (
<>
{thereIsNoProviders ? (
<NoProvidersAdded action="link" href={ADD_PROVIDER_HREF} />
<NoProvidersAdded onOpenWizard={openProviderWizard} />
) : (
<NoProvidersConnected />
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={setIsProviderWizardOpen}
/>
</>
);
}
-27
View File
@@ -1,27 +0,0 @@
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 aria-selected:text-slate-900 dark:data-[state=active]:text-white dark:aria-selected:text-white",
"data-[state=active]:text-slate-900 dark:data-[state=active]: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 aria-selected: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 [&: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",
@@ -1,159 +0,0 @@
import { Row } from "@tanstack/react-table";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
// The forms pull in server actions (`@/actions/users/users`) that can't run in
// jsdom, so stub them with identifiable markers to assert which modal opens.
vi.mock("../forms", () => ({
DeleteForm: ({ userId }: { userId: string }) => (
<div data-testid="delete-form">delete-form:{userId}</div>
),
EditForm: ({ userId }: { userId: string }) => (
<div data-testid="edit-form">edit-form:{userId}</div>
),
ExpelUserForm: ({ userId }: { userId: string }) => (
<div data-testid="expel-form">expel-form:{userId}</div>
),
}));
import { DataTableRowActions } from "./data-table-row-actions";
interface RowOptions {
id?: string;
isCurrentUser?: boolean;
canBeExpelled?: boolean;
currentTenantId?: string;
}
const createRow = ({
id = "user-1",
isCurrentUser,
canBeExpelled,
currentTenantId,
}: RowOptions = {}) =>
({
original: {
id,
attributes: {
name: "Jane Doe",
email: "jane@example.com",
company_name: "Acme",
role: { name: "admin" },
},
isCurrentUser,
canBeExpelled,
currentTenantId,
},
}) as unknown as Row<{ id: string }>;
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: "Open actions menu" }));
};
describe("DataTableRowActions (users)", () => {
it("always renders the Edit User action", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow()} />);
await openMenu(user);
expect(screen.getByText("Edit User")).toBeInTheDocument();
});
it("shows Delete User only for the current user's row", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
await openMenu(user);
expect(screen.getByText("Delete User")).toBeInTheDocument();
expect(screen.getByText("Danger zone")).toBeInTheDocument();
});
it("does NOT show Delete User for another user's row", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({ isCurrentUser: false })} />);
await openMenu(user);
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
});
it("does NOT show Delete User when isCurrentUser is undefined", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({})} />);
await openMenu(user);
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
});
it("hides the Danger zone entirely when the user can neither be deleted nor expelled", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow({ isCurrentUser: false, canBeExpelled: false })}
/>,
);
await openMenu(user);
// Only the non-destructive Edit action remains.
expect(screen.getByText("Edit User")).toBeInTheDocument();
expect(screen.queryByText("Danger zone")).not.toBeInTheDocument();
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
expect(
screen.queryByText("Expel from organization"),
).not.toBeInTheDocument();
});
it("shows Expel but not Delete User for an expellable, non-current user", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow({
isCurrentUser: false,
canBeExpelled: true,
currentTenantId: "tenant-1",
})}
/>,
);
await openMenu(user);
expect(screen.getByText("Danger zone")).toBeInTheDocument();
expect(screen.getByText("Expel from organization")).toBeInTheDocument();
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
});
it("renders Delete User with destructive styling", async () => {
const user = userEvent.setup();
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
await openMenu(user);
const menuItem = screen
.getByText("Delete User")
.closest("[role='menuitem']");
expect(menuItem).toBeInTheDocument();
expect(menuItem).toHaveClass("text-text-error-primary");
});
it("opens the delete confirmation modal when Delete User is selected", async () => {
const user = userEvent.setup();
render(
<DataTableRowActions
row={createRow({ id: "user-42", isCurrentUser: true })}
/>,
);
await openMenu(user);
await user.click(screen.getByText("Delete User"));
expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument();
expect(screen.getByTestId("delete-form")).toHaveTextContent(
"delete-form:user-42",
);
});
});
@@ -29,7 +29,6 @@ interface UserRowData {
attributes?: UserRowAttributes;
canBeExpelled?: boolean;
currentTenantId?: string;
isCurrentUser?: boolean;
}
interface DataTableRowActionsProps<UserProps extends UserRowData> {
@@ -58,10 +57,6 @@ export function DataTableRowActions<UserProps extends UserRowData>({
row.original.canBeExpelled === true && !!row.original.currentTenantId;
const currentTenantId = row.original.currentTenantId;
// A user can only delete their own account (enforced by the backend), so the
// delete action is shown exclusively for the current user's row.
const canDeleteUser = row.original.isCurrentUser === true;
return (
<>
<Modal
@@ -79,16 +74,14 @@ export function DataTableRowActions<UserProps extends UserRowData>({
setIsOpen={setIsEditOpen}
/>
</Modal>
{canDeleteUser && (
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
>
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
</Modal>
)}
<Modal
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
>
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
</Modal>
{canExpelUser && currentTenantId && (
<Modal
open={isExpelOpen}
@@ -111,26 +104,22 @@ export function DataTableRowActions<UserProps extends UserRowData>({
label="Edit User"
onSelect={() => setIsEditOpen(true)}
/>
{(canExpelUser || canDeleteUser) && (
<ActionDropdownDangerZone>
{canExpelUser && (
<ActionDropdownItem
icon={<UserMinus aria-hidden="true" />}
label="Expel from organization"
destructive
onSelect={() => setIsExpelOpen(true)}
/>
)}
{canDeleteUser && (
<ActionDropdownItem
icon={<Trash2 aria-hidden="true" />}
label="Delete User"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
)}
</ActionDropdownDangerZone>
)}
<ActionDropdownDangerZone>
{canExpelUser && (
<ActionDropdownItem
icon={<UserMinus aria-hidden="true" />}
label="Expel from organization"
destructive
onSelect={() => setIsExpelOpen(true)}
/>
)}
<ActionDropdownItem
icon={<Trash2 aria-hidden="true" />}
label="Delete User"
destructive
onSelect={() => setIsDeleteOpen(true)}
/>
</ActionDropdownDangerZone>
</ActionDropdown>
</div>
</>
+12 -12
View File
@@ -778,26 +778,26 @@
{
"section": "devDependencies",
"name": "@vitest/browser",
"from": "4.0.18",
"to": "4.1.8",
"from": "4.1.6",
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-06-02T11:34:46.264Z"
"generatedAt": "2026-05-14T10:22:47.378Z"
},
{
"section": "devDependencies",
"name": "@vitest/browser-playwright",
"from": "4.0.18",
"to": "4.1.8",
"from": "4.1.6",
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-06-02T11:34:46.264Z"
"generatedAt": "2026-05-14T10:22:47.378Z"
},
{
"section": "devDependencies",
"name": "@vitest/coverage-v8",
"from": "4.0.18",
"to": "4.1.8",
"from": "4.1.6",
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-06-02T11:34:46.264Z"
"generatedAt": "2026-05-14T10:22:47.378Z"
},
{
"section": "devDependencies",
@@ -978,10 +978,10 @@
{
"section": "devDependencies",
"name": "vitest",
"from": "4.0.18",
"to": "4.1.8",
"from": "4.1.6",
"to": "4.0.18",
"strategy": "installed",
"generatedAt": "2026-06-02T11:34:46.264Z"
"generatedAt": "2026-05-14T10:22:47.378Z"
},
{
"section": "devDependencies",
-3
View File
@@ -1,3 +0,0 @@
export const ADD_PROVIDER_SEARCH_PARAM = "addProvider";
export const ADD_PROVIDER_SEARCH_VALUE = "true";
export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`;
+4 -4
View File
@@ -133,9 +133,9 @@
"@typescript-eslint/eslint-plugin": "8.53.0",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"@vitest/browser": "4.1.8",
"@vitest/browser-playwright": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/browser": "4.0.18",
"@vitest/browser-playwright": "4.0.18",
"@vitest/coverage-v8": "4.0.18",
"babel-plugin-react-compiler": "1.0.0",
"dotenv": "16.6.1",
"dotenv-expand": "12.0.3",
@@ -158,7 +158,7 @@
"prettier-plugin-tailwindcss": "0.6.14",
"tailwindcss": "4.1.18",
"typescript": "5.5.4",
"vitest": "4.1.8",
"vitest": "4.0.18",
"vitest-browser-react": "2.0.4"
},
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
+110 -103
View File
@@ -325,14 +325,14 @@ importers:
specifier: 5.1.2
version: 5.1.2(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/browser':
specifier: 4.1.8
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
specifier: 4.0.18
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
'@vitest/browser-playwright':
specifier: 4.1.8
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
specifier: 4.0.18
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
'@vitest/coverage-v8':
specifier: 4.1.8
version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
specifier: 4.0.18
version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)
babel-plugin-react-compiler:
specifier: 1.0.0
version: 1.0.0
@@ -400,11 +400,11 @@ importers:
specifier: 5.5.4
version: 5.5.4
vitest:
specifier: 4.1.8
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
specifier: 4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
vitest-browser-react:
specifier: 2.0.4
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8)
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18)
packages:
@@ -737,9 +737,6 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
@@ -4798,54 +4795,54 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/browser-playwright@4.1.8':
resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==}
'@vitest/browser-playwright@4.0.18':
resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==}
peerDependencies:
playwright: '*'
vitest: 4.1.8
vitest: 4.0.18
'@vitest/browser@4.1.8':
resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==}
'@vitest/browser@4.0.18':
resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==}
peerDependencies:
vitest: 4.1.8
vitest: 4.0.18
'@vitest/coverage-v8@4.1.8':
resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
'@vitest/coverage-v8@4.0.18':
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
peerDependencies:
'@vitest/browser': 4.1.8
vitest: 4.1.8
'@vitest/browser': 4.0.18
vitest: 4.0.18
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.8':
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
'@vitest/expect@4.0.18':
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
'@vitest/mocker@4.1.8':
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
'@vitest/mocker@4.0.18':
resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
vite: ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.8':
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
'@vitest/pretty-format@4.0.18':
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
'@vitest/runner@4.1.8':
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
'@vitest/runner@4.0.18':
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
'@vitest/snapshot@4.1.8':
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
'@vitest/snapshot@4.0.18':
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
'@vitest/spy@4.1.8':
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
'@vitest/spy@4.0.18':
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
'@vitest/utils@4.1.8':
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
'@vitest/utils@4.0.18':
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -5051,8 +5048,8 @@ packages:
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
ast-v8-to-istanbul@1.0.3:
resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==}
ast-v8-to-istanbul@0.3.12:
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
@@ -5653,6 +5650,9 @@ packages:
resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
@@ -7246,6 +7246,10 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pixelmatch@7.1.0:
resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
hasBin: true
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
@@ -7533,7 +7537,6 @@ packages:
recharts@2.15.4:
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -7826,8 +7829,8 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
@@ -8344,23 +8347,20 @@ packages:
'@types/react-dom':
optional: true
vitest@4.1.8:
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
vitest@4.0.18:
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.8
'@vitest/browser-preview': 4.1.8
'@vitest/browser-webdriverio': 4.1.8
'@vitest/coverage-istanbul': 4.1.8
'@vitest/coverage-v8': 4.1.8
'@vitest/ui': 4.1.8
'@vitest/browser-playwright': 4.0.18
'@vitest/browser-preview': 4.0.18
'@vitest/browser-webdriverio': 4.0.18
'@vitest/ui': 4.0.18
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
@@ -8374,10 +8374,6 @@ packages:
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
@@ -9323,8 +9319,6 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blazediff/core@1.9.1': {}
'@braintree/sanitize-url@7.1.1': {}
'@cfworker/json-schema@4.1.1': {}
@@ -14267,29 +14261,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
'@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
dependencies:
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
playwright: 1.56.1
tinyrainbow: 3.1.0
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
'@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
dependencies:
'@blazediff/core': 1.9.1
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/utils': 4.1.8
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/utils': 4.0.18
magic-string: 0.30.21
pixelmatch: 7.1.0
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.1.0
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
ws: 8.20.1
transitivePeerDependencies:
- bufferutil
@@ -14297,62 +14291,60 @@ snapshots:
- utf-8-validate
- vite
'@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)':
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.8
ast-v8-to-istanbul: 1.0.3
'@vitest/utils': 4.0.18
ast-v8-to-istanbul: 0.3.12
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
istanbul-reports: 3.2.0
magicast: 0.5.2
obug: 2.1.1
std-env: 4.1.0
std-env: 3.10.0
tinyrainbow: 3.1.0
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
optionalDependencies:
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
'@vitest/expect@4.1.8':
'@vitest/expect@4.0.18':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.8
'@vitest/utils': 4.1.8
'@vitest/spy': 4.0.18
'@vitest/utils': 4.0.18
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
'@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.8
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
vite: 7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)
'@vitest/pretty-format@4.1.8':
'@vitest/pretty-format@4.0.18':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.8':
'@vitest/runner@4.0.18':
dependencies:
'@vitest/utils': 4.1.8
'@vitest/utils': 4.0.18
pathe: 2.0.3
'@vitest/snapshot@4.1.8':
'@vitest/snapshot@4.0.18':
dependencies:
'@vitest/pretty-format': 4.1.8
'@vitest/utils': 4.1.8
'@vitest/pretty-format': 4.0.18
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.8': {}
'@vitest/spy@4.0.18': {}
'@vitest/utils@4.1.8':
'@vitest/utils@4.0.18':
dependencies:
'@vitest/pretty-format': 4.1.8
convert-source-map: 2.0.0
'@vitest/pretty-format': 4.0.18
tinyrainbow: 3.1.0
'@webassemblyjs/ast@1.14.1':
@@ -14612,7 +14604,7 @@ snapshots:
ast-types-flow@0.0.8: {}
ast-v8-to-istanbul@1.0.3:
ast-v8-to-istanbul@0.3.12:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
@@ -15281,6 +15273,8 @@ snapshots:
iterator.prototype: 1.1.5
safe-array-concat: 1.1.3
es-module-lexer@1.7.0: {}
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
@@ -17286,6 +17280,10 @@ snapshots:
picomatch@4.0.4: {}
pixelmatch@7.1.0:
dependencies:
pngjs: 7.0.0
pkce-challenge@5.0.1: {}
pkg-types@1.3.1:
@@ -17982,7 +17980,7 @@ snapshots:
statuses@2.0.2: {}
std-env@4.1.0: {}
std-env@3.10.0: {}
stop-iteration-iterator@1.1.0:
dependencies:
@@ -18489,31 +18487,31 @@ snapshots:
terser: 5.47.1
yaml: 2.9.0
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8):
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
optionalDependencies:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)):
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0):
dependencies:
'@vitest/expect': 4.1.8
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.8
'@vitest/runner': 4.1.8
'@vitest/snapshot': 4.1.8
'@vitest/spy': 4.1.8
'@vitest/utils': 4.1.8
es-module-lexer: 2.1.0
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
'@vitest/spy': 4.0.18
'@vitest/utils': 4.0.18
es-module-lexer: 1.7.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 1.1.2
tinyglobby: 0.2.16
@@ -18523,11 +18521,20 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.10.8
'@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
'@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
'@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
jsdom: 27.4.0
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
w3c-keyname@2.2.8: {}
+4 -27
View File
@@ -132,24 +132,6 @@ export async function addAWSProvider(
await scansPage.verifyPageLoaded();
}
/**
* Waits for the providers page to settle and reports whether the data table is
* present. With zero providers the page renders a full-page empty state
* ("No Providers Configured") instead of the table, so callers must not assume
* the table is always there.
*/
async function providersTableVisibleOrEmptyState(
page: ProvidersPage,
): Promise<boolean> {
const emptyState = page.page.getByRole("region", {
name: /no providers configured/i,
});
await expect(page.providersTable.or(emptyState)).toBeVisible({
timeout: 10000,
});
return page.providersTable.isVisible().catch(() => false);
}
export async function deleteProviderIfExists(
page: ProvidersPage,
providerUID: string,
@@ -158,11 +140,7 @@ export async function deleteProviderIfExists(
// Navigate to providers page
await page.goto();
// With zero providers the page shows the empty state, not the table, so there
// is nothing to delete.
if (!(await providersTableVisibleOrEmptyState(page))) {
return;
}
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
const allRows = page.providersTable.locator("tbody tr");
@@ -202,7 +180,7 @@ export async function deleteProviderIfExists(
// Provider not found, nothing to delete
// Navigate back to providers page to ensure clean state
await page.goto();
await providersTableVisibleOrEmptyState(page);
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
return;
}
@@ -239,8 +217,7 @@ export async function deleteProviderIfExists(
// Wait for modal to close (this indicates deletion was initiated)
await expect(modal).not.toBeVisible({ timeout: 10000 });
// Navigate back to providers page to ensure clean state. Deleting the last
// provider reveals the empty state instead of an empty table.
// Navigate back to providers page to ensure clean state
await page.goto();
await providersTableVisibleOrEmptyState(page);
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
}
+2 -7
View File
@@ -341,10 +341,7 @@ export class ProvidersPage extends BasePage {
name: /Adding A Provider|Update Provider Credentials/i,
});
// Button to add a new provider. When providers exist this is the filter-bar
// "Add Provider" control; with zero providers the page renders the empty
// state whose CTA is labelled "Open Add Provider modal" (button on
// /providers, link on /scans). Only one of these is ever in the DOM at once.
// Button to add a new provider
this.addProviderButton = page
.getByRole("button", {
name: "Add Provider",
@@ -355,9 +352,7 @@ export class ProvidersPage extends BasePage {
name: "Add Provider",
exact: true,
}),
)
.or(page.getByRole("button", { name: "Open Add Provider modal" }))
.or(page.getByRole("link", { name: "Open Add Provider modal" }));
);
// Table displaying existing providers
this.providersTable = page.getByRole("table");
Generated
+25 -21
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.10, <3.13"
resolution-markers = [
"python_full_version >= '3.12'",
@@ -1774,29 +1774,33 @@ wheels = [
[[package]]
name = "dulwich"
version = "0.23.0"
version = "1.2.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/ac/ba58cf420640c7bc77ae8e1b31e174d83c9117750c63cf9ea3b5e202e5c4/dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae", size = 575116, upload-time = "2025-06-21T17:56:47.494Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/3d/7ea85d70d85f7d5ed5bf28dc742f106d8334e84286fbc852d983273dd890/dulwich-1.2.6.tar.gz", hash = "sha256:405cfd53a99374ff03aacdd7a86d6a07615feca072ed69721f49ae2ebaa3eab4", size = 1257895, upload-time = "2026-05-31T14:32:52.758Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/8d/d725f0c9ddb218c7d9e3e02ee4545e998b57e1d7c12f5ab3e2d61f577410/dulwich-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c13b0d5a9009cde23ecb8cb201df6e23e2a7a82c5e2d6ba6443fbb322c9befc6", size = 973413, upload-time = "2025-06-21T17:56:04.641Z" },
{ url = "https://files.pythonhosted.org/packages/97/82/0316022bd64b3525acfebc88b6b7506d04b0402b7dbfb746cd15529b9ea8/dulwich-0.23.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:a68faf8612bf93de1285048d6ad13160f0fb3c5596a86e694e78f4e212886fa5", size = 1050614, upload-time = "2025-06-21T17:56:07.084Z" },
{ url = "https://files.pythonhosted.org/packages/65/a0/e3f71d6d74809cd9245d3d2921448fd32a8417f74b4e912e82cef0cf5098/dulwich-0.23.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d971566826f16ec67c70641c1fbdb337323aa5b533799bc5a4641f4750e73b36", size = 1052830, upload-time = "2025-06-21T17:56:08.682Z" },
{ url = "https://files.pythonhosted.org/packages/c1/38/8dd887d9b64f47f8097e207ed7e8d5dd640a19aa763e632d97174961585f/dulwich-0.23.0-cp310-cp310-win32.whl", hash = "sha256:27d970adf539806dfc4fe3e4c9e8dc6ebf0318977a56e24d22f13413535a51ba", size = 642779, upload-time = "2025-06-21T17:56:10.391Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ca/a345085526ac3b7aaa891ca4ec7ad9375cd8d017e42d4dbf20a443231275/dulwich-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:025178533e884ffdb0d9d8db4b8870745d438cbfecb782fd1b56c3b6438e86cf", size = 658637, upload-time = "2025-06-21T17:56:12.093Z" },
{ url = "https://files.pythonhosted.org/packages/ae/11/f6bbba8583f69cf19ef4bd7f5fde1a6b5ccaf8b6951781cec8db247116f4/dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc", size = 972658, upload-time = "2025-06-21T17:56:13.505Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9d/2720e0ab58666378a33c752a61543f936cd6b06dfe5d84a2215ddc0914b0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527", size = 1049813, upload-time = "2025-06-21T17:56:14.884Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f3/81d8075141dfcc0a0449c2093596e58d3e11444e3af54e819eca63b84dd0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261", size = 1051639, upload-time = "2025-06-21T17:56:16.437Z" },
{ url = "https://files.pythonhosted.org/packages/4f/0d/c06ccb227b096aef5906142fe78b5c79f9070a0ea6152fc219941186d540/dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a", size = 642918, upload-time = "2025-06-21T17:56:18.373Z" },
{ url = "https://files.pythonhosted.org/packages/d7/1c/1e99aa34c9aead9e641b2d9934f0a3d00257f75027cf5cdecc8a1a6c18ae/dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd", size = 659010, upload-time = "2025-06-21T17:56:19.947Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d7/1e6fba0235babe912e8467b036062e37d11672cbbeb0d8074f9d4559057b/dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e", size = 960292, upload-time = "2025-06-21T17:56:21.308Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6a/23f0c487ec03f2752600cab4a8e0dedb38186246c475bf3fa90a8db830d5/dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e", size = 1047892, upload-time = "2025-06-21T17:56:22.989Z" },
{ url = "https://files.pythonhosted.org/packages/c7/e2/8f3d216be5fd0ee1180d917b59b34b54b9896384cf139f319b5d3a8f16b4/dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9", size = 1048699, upload-time = "2025-06-21T17:56:24.602Z" },
{ url = "https://files.pythonhosted.org/packages/8f/c4/18e6223cd4ad1ae9334eb4e6aa5952fd8f5c3d75762918eb90c209fec4ba/dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf", size = 641268, upload-time = "2025-06-21T17:56:26.18Z" },
{ url = "https://files.pythonhosted.org/packages/b8/9c/65bfbbac62d8a2967e13f6a1512371c5eb6b906a61fb6dead992669cad0e/dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59", size = 657837, upload-time = "2025-06-21T17:56:27.821Z" },
{ url = "https://files.pythonhosted.org/packages/35/31/49318ee9db4b402e6d8b9b01bd4cae9298f59e1bb9bd56cf4a94e48fa069/dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135", size = 313776, upload-time = "2025-06-21T17:56:46.221Z" },
{ url = "https://files.pythonhosted.org/packages/a7/49/df8cf771b132981ca0d1d8229776994d87e403b610dbc606338657dc4fb1/dulwich-1.2.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9139d0110580a3038048286e761e9be166ec40a2eb19218b41b75541c5d87a86", size = 1401745, upload-time = "2026-05-31T14:31:52.714Z" },
{ url = "https://files.pythonhosted.org/packages/69/09/cc716d5f8cd4003786c32b2384e7d5626f706fd93a63d406a081e1bc4d93/dulwich-1.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cf80217e73a039614dde5ab2c74917833632912b788074bc7158058aafbf3e5", size = 1384507, upload-time = "2026-05-31T14:31:54.642Z" },
{ url = "https://files.pythonhosted.org/packages/73/ba/91fe15a707b5494081458af8c937025ffb6fabe866b8a1aefa9627534c56/dulwich-1.2.6-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:fa7a089298fcbdaed493dd25c2f13574ccfc708f89a7aae8e3c25fd8393f5c81", size = 1473324, upload-time = "2026-05-31T14:31:56.38Z" },
{ url = "https://files.pythonhosted.org/packages/7b/b8/e2acf26d4ca5824f113fc0e3f6bc385ce63ef3fcd07acb5033b835760eae/dulwich-1.2.6-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6fcbb3dec5733898be2114476ff5abaa1dbb8a6d28ffbe492b3225a5a556197e", size = 1499519, upload-time = "2026-05-31T14:31:57.795Z" },
{ url = "https://files.pythonhosted.org/packages/1a/47/7117c70233e27b0413d154e88ed49235485ff3e07f710b269fdc3fa0e5be/dulwich-1.2.6-cp310-cp310-win32.whl", hash = "sha256:493e2ea0f23a8e9aae8e3000a366d1fbf0ed2c13eaf8f41863f050c6392ef138", size = 1068500, upload-time = "2026-05-31T14:31:59.045Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e7/62b3be2f19df7db367a1d5453132a946c91f974397f84b055106fa96fa8e/dulwich-1.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:72ac4f3fc92d54115ba2d812263117d9577b17f4c62ae8f170c177515f62e9d3", size = 1081079, upload-time = "2026-05-31T14:32:00.627Z" },
{ url = "https://files.pythonhosted.org/packages/37/ea/c54b0a87815e06baeb541c17e492c2e3fb7b9f216dc2033e3a356078270c/dulwich-1.2.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e103584421b7205f022bd413a324ff26905ffa84fcc1536f5787bf554d5d390b", size = 1400786, upload-time = "2026-05-31T14:32:01.994Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d1/5ca58eb2d1160d52ac2d109da1b4bd6c332a1a803fa6cb7ca7cda5f37431/dulwich-1.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e357d825b82e7fec2b83cd8e50f3c099c14c1070e1df961bfefb83943dc1582", size = 1383818, upload-time = "2026-05-31T14:32:03.444Z" },
{ url = "https://files.pythonhosted.org/packages/33/25/3dc9960cbdeef59fed4c07df52c17eacb3c5515b24cff64c524cdc75b563/dulwich-1.2.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:11b1f5a6a6075ab4f906dfb755c1d805c8c898ba4f4816b0fdb6123e113030ac", size = 1472506, upload-time = "2026-05-31T14:32:04.885Z" },
{ url = "https://files.pythonhosted.org/packages/44/98/39dd7470d37609a62c66bb59d298f871fd835d37580cc870c5b8a66ea87e/dulwich-1.2.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6d9720d591052730775dcbf450f0cd5b35162f4eeb4754337a5d763326481b2f", size = 1499334, upload-time = "2026-05-31T14:32:06.186Z" },
{ url = "https://files.pythonhosted.org/packages/48/f9/504b6e0c9f26bca62dc54cf5d6a65c807098856b6732279e37a9c034acab/dulwich-1.2.6-cp311-cp311-win32.whl", hash = "sha256:371394e2c6f3f9789cdc0abb965dae9bc62e79984b84f35339e9d466598c9fb0", size = 1068084, upload-time = "2026-05-31T14:32:07.466Z" },
{ url = "https://files.pythonhosted.org/packages/15/b6/ee75e1916984716cb57adf0d1f95e7b241d4accc4dc4d1ae3a9ddba1a411/dulwich-1.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:f887643cf1c7a04e898547bd9f0acf6654d772ebd153012433ef950315dcf776", size = 1081023, upload-time = "2026-05-31T14:32:08.889Z" },
{ url = "https://files.pythonhosted.org/packages/d9/80/496b2f8d584a7ba28519fd552d10c070498a76a17a92d288f1263e8e577d/dulwich-1.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:116ac7decb923a473540bf813c1ceb061bef07209fad5fb002d867f1907f9393", size = 1398028, upload-time = "2026-05-31T14:32:10.25Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ce/2ac1ccb8f5c039a93b3e6c1fc9f06ded05eb4adf7e934a643894389be755/dulwich-1.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6993ad48f92dc38a43e3c1bf25efb03a62fc2cf4db86a2e904b6c7176dafc3d5", size = 1336335, upload-time = "2026-05-31T14:32:11.64Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7d/7e1e736b9dfdb8aacf474c29d2b2fe331a23e1aa3741428a87280a73dbb9/dulwich-1.2.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:72512e2a22df6fb65ba7b66f5037046019a12343f6e9e54f42bcc4a68ab3d628", size = 1418127, upload-time = "2026-05-31T14:32:13.124Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ab/fc716cd97d35335a6bc02bea4e24bb1c1f0947731a421c2038b6c250c0b2/dulwich-1.2.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e995ad77b0685747bdb51f7a5cd7e6cb8efe73e29517b0f2c95fc2e6d10d5a90", size = 1442869, upload-time = "2026-05-31T14:32:14.532Z" },
{ url = "https://files.pythonhosted.org/packages/67/35/79dcfbafa5d3c6271da71a6b016e80f1a393a50c013958fd1d7c3375f284/dulwich-1.2.6-cp312-cp312-win32.whl", hash = "sha256:4940fbf7cb37870686c63dfc7682e1afdab0e55b663bb614572909b68e775d31", size = 1018595, upload-time = "2026-05-31T14:32:15.805Z" },
{ url = "https://files.pythonhosted.org/packages/63/9a/b33d7e6749417552fbf065fd734395a90b7b5d27a377149fbb837aea8127/dulwich-1.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c60ddc8206e04e8e08208eac80130004eff0d587c82d398beeca7330cade061f", size = 1033271, upload-time = "2026-05-31T14:32:17.27Z" },
{ url = "https://files.pythonhosted.org/packages/24/15/61bd455d33979584f19d3a6e0b49b49e0d891bc680fc8cc7b028aea7360d/dulwich-1.2.6-py3-none-any.whl", hash = "sha256:8d8175dbe4feaf62bcafc8708448bfe223b4dfc71609be25c0cf2b0962abc36c", size = 688260, upload-time = "2026-05-31T14:32:51.285Z" },
]
[[package]]
@@ -3241,7 +3245,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.29.4"
version = "5.30.0"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
@@ -3403,7 +3407,7 @@ requires-dist = [
{ name = "dash-bootstrap-components", specifier = "==2.0.3" },
{ name = "defusedxml", specifier = "==0.7.1" },
{ name = "detect-secrets", specifier = "==1.5.0" },
{ name = "dulwich", specifier = "==0.23.0" },
{ name = "dulwich", specifier = "==1.2.6" },
{ name = "google-api-python-client", specifier = "==2.163.0" },
{ name = "google-auth-httplib2", specifier = "==0.2.0" },
{ name = "h2", specifier = "==4.3.0" },