Compare commits

...

17 Commits

Author SHA1 Message Date
Prowler Bot 21e7f29153 fix(ui): show delete user action only for the current user (#11458)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-03 17:12:55 +02:00
Prowler Bot de51eed96c fix(ui): refine add-provider wizard flow between scans and providers (#11457)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-06-03 16:30:18 +02:00
Prowler Bot 835dbddc6a chore(release): Bump versions to v5.29.2 (#11446)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-03 10:52:45 +02:00
Prowler Bot 103761f146 perf(api): avoid N+1 query loading finding resource tags (#11426)
Co-authored-by: Davidm4r <david.copo@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-02 13:50:56 +02:00
Prowler Bot 0e6268e159 fix(api): clean up scan tmp output failure to avoid disk fill (#11423)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-02 11:58:04 +02:00
Prowler Bot f48984e6a1 chore(release): Bump versions to v5.29.1 (#11417)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-01 18:35:25 +02:00
Prowler Bot 6df80a4890 chore(api): Update prowler dependency to v5.29 for release 5.29.0 (#11414)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-01 16:26:56 +02:00
Alejandro Bailo a769e37615 fix(ui): restore scheduled scan column (#11411) 2026-06-01 14:34:58 +02:00
Alejandro Bailo 9d2a8d9108 fix(ui): improve background glow contrast (#11409) 2026-06-01 14:25:23 +02:00
Alejandro Bailo e05519ff9f fix(ui): refine scans tabs and provider launch flow (#11407) 2026-06-01 12:34:11 +02:00
Pedro Martín 67b26072f8 docs(installation): add info about updating prowler (#11404) 2026-06-01 11:15:07 +02:00
lydiavilchez 2222082631 fix(googleworkspace): update metadata urls to point to official documentation (#11405) 2026-06-01 10:52:32 +02:00
Pepe Fagoaga 8b0cb4b981 chore: fix SDK changelog for v5.29 (#11392) 2026-05-29 18:23:36 +02:00
Pepe Fagoaga 9422eff8ab chore: changelog v5.29.0 (#11390)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-29 17:29:52 +02:00
Br1an e3c4368d32 fix(azure): pass authority to credentials for sovereign clouds (#10284)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-05-29 15:17:41 +02:00
OokaToru 2a641b39c8 chore(s3): deprecate s3_bucket_default_encryption check (#11230)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-05-29 14:41:52 +02:00
Alejandro Bailo 02b713572b test(ui): find scheduled scan e2e row in In Progress tab (#11385) 2026-05-29 10:55:16 +02:00
131 changed files with 3017 additions and 727 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.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.2
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+11 -1
View File
@@ -2,11 +2,21 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.30.0] (Prowler UNRELEASED)
## [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
- Scan finding ingestion: bulk-resolve `Resource`/`ResourceTag` rows, replace per-mapping `SELECT FOR UPDATE` with deferred `ResourceTagMapping.bulk_create(ignore_conflicts=True)`, wrap each micro-batch in a single `rls_transaction`, and raise `SCAN_DB_BATCH_SIZE` to 1000 [(#11249)](https://github.com/prowler-cloud/prowler/pull/11249)
- Faster `GET /api/v1/finding-groups/latest` aggregation on tenants where one recent scan holds most findings [(#11380)](https://github.com/prowler-cloud/prowler/pull/11380)
---
+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@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.29",
"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.0"
version = "1.30.2"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.30.0
version: 1.30.2
description: |-
Prowler API specification.
+228 -13
View File
@@ -24,9 +24,11 @@ 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
@@ -64,6 +66,7 @@ from api.models import (
ProviderSecret,
Resource,
ResourceFindingMapping,
ResourceTag,
Role,
RoleProviderGroupRelationship,
SAMLConfiguration,
@@ -3856,16 +3859,20 @@ class TestScanViewSet:
scan.output_location = "dummy"
scan.save()
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}
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}
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}),
),
with 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)
@@ -4186,6 +4193,88 @@ 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
):
@@ -4294,18 +4383,24 @@ 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, mock_task_get, authenticated_client, scans_fixture
self, mock_task_serializer, authenticated_client, scans_fixture
):
scan = scans_fixture[0]
scan.state = StateChoices.COMPLETED
scan.output_location = "dummy"
scan.save()
task = Task.objects.create(tenant_id=scan.tenant_id)
mock_task_get.return_value = task
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,
)
mock_task_serializer.return_value.data = {
"id": str(task.id),
"state": StateChoices.COMPLETED,
@@ -4326,6 +4421,7 @@ class TestScanViewSet:
scan.save()
task_result = TaskResult.objects.create(
task_id=str(uuid4()),
task_name="scan-report",
task_kwargs={"scan_id": str(scan.id)},
)
@@ -4346,6 +4442,51 @@ 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(
@@ -6916,6 +7057,80 @@ 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",
[
+33 -11
View File
@@ -2059,12 +2059,17 @@ class ScanViewSet(BaseRLSViewSet):
if scan_instance.state == StateChoices.EXECUTING and scan_instance.task:
task = scan_instance.task
else:
try:
task = Task.objects.get(
# 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(
task_runner_task__task_name="scan-report",
task_runner_task__task_kwargs__contains=str(scan_instance.id),
)
except Task.DoesNotExist:
.order_by("-inserted_at")
.first()
)
if task is None:
return None
self.response_serializer_class = TaskSerializer
@@ -2139,27 +2144,32 @@ class ScanViewSet(BaseRLSViewSet):
status=status.HTTP_502_BAD_GATEWAY,
)
contents = resp.get("Contents", [])
keys = []
matches = []
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):
keys.append(key)
matches.append(obj)
elif key_basename == suffix:
keys.append(key)
matches.append(obj)
elif key.endswith(suffix):
# Backward compatibility if suffix already includes directories
keys.append(key)
if not keys:
matches.append(obj)
if not matches:
return Response(
{
"detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'."
},
status=status.HTTP_404_NOT_FOUND,
)
# path_pattern here is prefix, but in compliance we build correct suffix check before
key = keys[0]
# 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"
]
else:
# path_pattern is exact key; HEAD before presigning to preserve the 404 contract.
key = path_pattern
@@ -2209,7 +2219,9 @@ class ScanViewSet(BaseRLSViewSet):
},
status=status.HTTP_404_NOT_FOUND,
)
filepath = files[0]
# Return the most recently modified match (latest report) when the
# pattern resolves to several files.
filepath = max(files, key=os.path.getmtime)
with open(filepath, "rb") as f:
content = f.read()
filename = os.path.basename(filepath)
@@ -3749,6 +3761,16 @@ 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(
+28 -2
View File
@@ -467,8 +467,31 @@ 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=RLSTask,
base=ScanReportRLSTask,
name="scan-report",
queue="scan-reports",
)
@@ -518,6 +541,9 @@ 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):
"""
@@ -666,7 +692,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(Path(compressed).parent, ignore_errors=True)
rmtree(scan_tmp_dir, 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,8 +15,10 @@ 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,
@@ -771,6 +773,38 @@ 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
+54 -4
View File
@@ -4410,8 +4410,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.27.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
version = "5.29.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29#a769e3761532d9332cb64078ef09ebf7ffb15292" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4484,9 +4484,13 @@ 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" },
@@ -4494,7 +4498,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.30.0"
version = "1.30.2"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4590,7 +4594,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=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -5526,6 +5530,52 @@ 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"
@@ -40,12 +40,6 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
pip install prowler
prowler -v
```
To upgrade Prowler to the latest version:
``` bash
pip install --upgrade prowler
```
</Tab>
<Tab title="Docker">
_Requirements_:
@@ -170,6 +164,68 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i
</Tab>
</Tabs>
## Updating Prowler CLI
Upgrade Prowler CLI to the latest release using the same method chosen for installation:
<Tabs>
<Tab title="pipx">
```bash
pipx upgrade prowler
prowler -v
```
</Tab>
<Tab title="pip">
```bash
pip install --upgrade prowler
prowler -v
```
</Tab>
<Tab title="Docker">
Pull the desired image tag to fetch the latest version:
```bash
docker pull toniblyx/prowler:latest
```
<Note>
Replace `latest` with a specific release tag (for example, `stable` or `<x.y.z>`) to pin a version. Refer to the [Container Versions](#container-versions) section for the full list of available tags.
</Note>
</Tab>
<Tab title="GitHub">
Pull the latest changes and sync the environment:
```bash
cd prowler
git pull
uv sync
uv run python prowler-cli.py -v
```
<Note>
To upgrade to a specific release, check out the corresponding tag before syncing: `git checkout <x.y.z>`.
</Note>
</Tab>
<Tab title="Brew">
```bash
brew upgrade prowler
prowler -v
```
</Tab>
<Tab title="CloudShell">
Both AWS CloudShell and Azure CloudShell install Prowler with `pipx`, so the upgrade command is the same:
```bash
pipx upgrade prowler
prowler -v
```
</Tab>
</Tabs>
<Note>
To install a specific version instead of the latest release, pin it explicitly. For example, with `pipx`: `pipx install prowler==<x.y.z>`, or with `pip`: `pip install prowler==<x.y.z>`. The available releases are listed in the [Releases GitHub section](https://github.com/prowler-cloud/prowler/releases).
</Note>
## Container Versions
The available versions of Prowler CLI are the following:
@@ -141,6 +141,45 @@ Choose one of the following installation methods:
---
## Updating Prowler MCP Server
When running Prowler MCP Server locally ("Option 2: Run Locally"), upgrade to the latest version using the same method chosen for installation. The hosted server (`https://mcp.prowler.com/mcp`) is always kept up to date by Prowler and requires no action.
<Tabs>
<Tab title="Docker">
Pull the latest image and restart the container:
```bash
docker pull prowlercloud/prowler-mcp
```
<Note>
Recreate any running container after pulling the new image so the updated version takes effect.
</Note>
</Tab>
<Tab title="From Source">
Pull the latest changes and sync the dependencies:
```bash
cd prowler/mcp_server
git pull
uv sync
uv run prowler-mcp --help
```
</Tab>
<Tab title="Build Docker Image">
Pull the latest source and rebuild the image:
```bash
cd prowler/mcp_server
git pull
docker build -t prowler-mcp .
```
</Tab>
</Tabs>
---
## Command Line Options
The Prowler MCP Server supports the following command-line arguments:
@@ -47,7 +47,11 @@ 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 will be able to delete himself and not others, regardless of his permissions.**
> **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**.
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
+17 -3
View File
@@ -2,26 +2,40 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.29.0] (Prowler UNRELEASED)
## [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
- `application` service for Okta provider with `application_admin_console_session_idle_timeout_15min`, `application_admin_console_mfa_required`, `application_admin_console_phishing_resistant_authentication`, `application_dashboard_mfa_required`, `application_dashboard_phishing_resistant_authentication`, and `application_authentication_policy_network_zone_enforced` checks [(#11358)](https://github.com/prowler-cloud/prowler/pull/11358)
- AWS AI Security Framework compliance for AWS provider [(#11353)](https://github.com/prowler-cloud/prowler/pull/11353)
- `storage_account_public_network_access_disabled` check for Azure provider and remapped the Azure CIS "Public Network Access is Disabled" requirements to it [(#11334)](https://github.com/prowler-cloud/prowler/pull/11334)
- StackIT provider now authenticates with a service account key, either as a file path (`--stackit-service-account-key-path` / `STACKIT_SERVICE_ACCOUNT_KEY_PATH`) or as inline JSON content (`--stackit-service-account-key` / `STACKIT_SERVICE_ACCOUNT_KEY`, intended for CI/CD with a secret manager); the StackIT SDK refreshes access tokens internally, replacing the short-lived `STACKIT_API_TOKEN` flow [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237)
- StackIT provider with service account key authentication [(#9237)](https://github.com/prowler-cloud/prowler/pull/9237)
- 8 Rules service checks for Google Workspace provider using the Cloud Identity Policy API [(#11379)](https://github.com/prowler-cloud/prowler/pull/11379)
- 12 Security service checks for Google Workspace provider using the Cloud Identity Policy API [(#11356)](https://github.com/prowler-cloud/prowler/pull/11356)
### ⚠️ Deprecated
- `s3_bucket_default_encryption` check for AWS provider since SSE-S3 is automatically applied to all S3 buckets by AWS as of January 5, 2023 and can no longer be disabled [(#11230)](https://github.com/prowler-cloud/prowler/pull/11230)
### 🐞 Fixed
- Broken documentation URLs in Google Workspace check metadata [(#11405)](https://github.com/prowler-cloud/prowler/pull/11405)
- ENS RD 311/2022 (AWS) compliance mapping: `vpc_different_regions` was uncorrectly mapped under the `mp.com.4` family (Network segregation). That check is now mapped to a new `op.cont.2.aws.vpc.1` requirement under the Continuity of Service control [(#11372)](https://github.com/prowler-cloud/prowler/pull/11372)
- Compliance CSV row count now matches the UI per requirement by sourcing rows from the framework JSON's `requirement.Checks` instead of the stale `finding.compliance` snapshot [(#11370)](https://github.com/prowler-cloud/prowler/pull/11370)
- OpenStack provider exception codes moved from the `10000-10999` range, shared with the AlibabaCloud provider, to the free `17000-17999` range to keep error codes unambiguous [(#11382)](https://github.com/prowler-cloud/prowler/pull/11382)
- Azure provider authentication against sovereign clouds (`AzureChinaCloud`, `AzureUSGovernment`) [(#10284)](https://github.com/prowler-cloud/prowler/pull/10284)
---
## [5.28.1] (Prowler 5.28.1)
## [5.28.1] (Prowler v5.28.1)
### 🐞 Fixed
+1 -1
View File
@@ -48,7 +48,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.29.0"
prowler_version = "5.29.2"
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"
+8
View File
@@ -227,6 +227,10 @@ 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}"
@@ -239,6 +243,10 @@ 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}"
@@ -1,7 +1,7 @@
{
"Provider": "aws",
"CheckID": "s3_bucket_default_encryption",
"CheckTitle": "S3 bucket has default server-side encryption (SSE) enabled",
"CheckTitle": "[DEPRECATED] S3 bucket has default server-side encryption (SSE) enabled",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
@@ -14,13 +14,11 @@
"Severity": "medium",
"ResourceType": "AwsS3Bucket",
"ResourceGroup": "storage",
"Description": "**Amazon S3 buckets** have a default **server-side encryption** setting that automatically encrypts new objects using `SSE-S3` or `SSE-KMS`. This evaluates whether a bucket has a default encryption configuration defined.",
"Description": "[DEPRECATED] **Amazon S3 buckets** have a default **server-side encryption** setting that automatically encrypts new objects using `SSE-S3` or `SSE-KMS`. This evaluates whether a bucket has a default encryption configuration defined.",
"Risk": "Without default encryption, older objects may remain unencrypted and new uploads won't be forced to use `SSE-KMS`. This reduces confidentiality and governance by limiting key audit logs, rotation, and cross-account controls, and increases exposure if data is copied, replicated, or accessed outside intended paths.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.amazonaws.cn/en_us/AmazonS3/latest/userguide/bucket-encryption.html",
"https://aws.amazon.com/blogs/security/how-to-prevent-uploads-of-unencrypted-objects-to-amazon-s3/",
"https://docs.aws.amazon.com/us_en/AmazonS3/latest/userguide/default-encryption-faq.html"
"https://docs.aws.amazon.com/AmazonS3/latest/userguide/default-encryption-faq.html"
],
"Remediation": {
"Code": {
@@ -39,5 +37,5 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "This check is being deprecated since AWS automatically applies SSE-S3 to every S3 bucket (both new buckets and previously-unencrypted existing buckets) as of January 5, 2023, and encryption can no longer be disabled. For SSE-KMS validation, use `s3_bucket_kms_encryption` instead."
}
+50 -9
View File
@@ -241,7 +241,10 @@ class AzureProvider(Provider):
azure_credentials = None
if tenant_id and client_id and client_secret:
azure_credentials = self.validate_static_credentials(
tenant_id=tenant_id, client_id=client_id, client_secret=client_secret
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
region_config=self._region_config,
)
# Set up the Azure session
@@ -410,6 +413,9 @@ class AzureProvider(Provider):
authority=config["authority"],
base_url=config["base_url"],
credential_scopes=config["credential_scopes"],
graph_host=config["graph_host"],
graph_scope=config["graph_scope"],
logs_endpoint=config["logs_endpoint"],
)
except ArgumentTypeError as validation_error:
logger.error(
@@ -507,6 +513,7 @@ class AzureProvider(Provider):
tenant_id=azure_credentials["tenant_id"],
client_id=azure_credentials["client_id"],
client_secret=azure_credentials["client_secret"],
authority=region_config.authority,
)
return credentials
except ClientAuthenticationError as error:
@@ -579,7 +586,10 @@ class AzureProvider(Provider):
)
else:
try:
credentials = InteractiveBrowserCredential(tenant_id=tenant_id)
credentials = InteractiveBrowserCredential(
tenant_id=tenant_id,
authority=region_config.authority,
)
except Exception as error:
logger.critical(
"Failed to retrieve azure credentials using browser authentication"
@@ -662,6 +672,7 @@ class AzureProvider(Provider):
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
region_config=region_config,
)
# Set up the Azure session
@@ -675,7 +686,11 @@ class AzureProvider(Provider):
region_config,
)
# Create a SubscriptionClient
subscription_client = SubscriptionClient(credentials)
subscription_client = SubscriptionClient(
credentials,
base_url=region_config.base_url,
credential_scopes=region_config.credential_scopes,
)
# Get info from the subscriptions
available_subscriptions = []
@@ -1039,7 +1054,11 @@ class AzureProvider(Provider):
}
"""
credentials = self.session
subscription_client = SubscriptionClient(credentials)
subscription_client = SubscriptionClient(
credentials,
base_url=self.region_config.base_url,
credential_scopes=self.region_config.credential_scopes,
)
locations = {}
for subscription_id, display_name in self._identity.subscriptions.items():
@@ -1084,7 +1103,10 @@ class AzureProvider(Provider):
@staticmethod
def validate_static_credentials(
tenant_id: str = None, client_id: str = None, client_secret: str = None
tenant_id: str = None,
client_id: str = None,
client_secret: str = None,
region_config: AzureRegionConfig = None,
) -> dict:
"""
Validates the static credentials for the Azure provider.
@@ -1093,6 +1115,9 @@ class AzureProvider(Provider):
tenant_id (str): The Azure Active Directory tenant ID.
client_id (str): The Azure client ID.
client_secret (str): The Azure client secret.
region_config (AzureRegionConfig): The region configuration used to
build the per-cloud login endpoint and Graph scope. Defaults to
the public-cloud configuration when not provided.
Raises:
AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid.
@@ -1129,8 +1154,13 @@ class AzureProvider(Provider):
message="The provided Azure Client Secret is not valid.",
)
if region_config is None:
region_config = AzureProvider.setup_region_config("AzureCloud")
try:
AzureProvider.verify_client(tenant_id, client_id, client_secret)
AzureProvider.verify_client(
tenant_id, client_id, client_secret, region_config
)
return {
"tenant_id": tenant_id,
"client_id": client_id,
@@ -1162,7 +1192,9 @@ class AzureProvider(Provider):
)
@staticmethod
def verify_client(tenant_id, client_id, client_secret) -> None:
def verify_client(
tenant_id, client_id, client_secret, region_config: AzureRegionConfig = None
) -> None:
"""
Verifies the Azure client credentials using the specified tenant ID, client ID, and client secret.
@@ -1170,6 +1202,9 @@ class AzureProvider(Provider):
tenant_id (str): The Azure Active Directory tenant ID.
client_id (str): The Azure client ID.
client_secret (str): The Azure client secret.
region_config (AzureRegionConfig): The region configuration used to
build the per-cloud login endpoint and Graph scope. Defaults to
the public-cloud configuration when not provided.
Raises:
AzureNotValidTenantIdError: If the provided Azure Tenant ID is not valid.
@@ -1179,7 +1214,13 @@ class AzureProvider(Provider):
Returns:
None
"""
url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
if region_config is None:
region_config = AzureProvider.setup_region_config("AzureCloud")
# `authority` is None for the public cloud and a bare host (e.g.
# `login.chinacloudapi.cn`) for sovereign clouds, mirroring the
# `AzureAuthorityHosts` constants used by azure-identity.
login_endpoint = region_config.authority or "login.microsoftonline.com"
url = f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
@@ -1188,7 +1229,7 @@ class AzureProvider(Provider):
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "https://graph.microsoft.com/.default",
"scope": region_config.graph_scope,
}
response = requests.post(url, headers=headers, data=data).json()
if "access_token" not in response.keys() and "error_codes" in response.keys():
@@ -4,6 +4,18 @@ AZURE_CHINA_CLOUD = "https://management.chinacloudapi.cn"
AZURE_US_GOV_CLOUD = "https://management.usgovcloudapi.net"
AZURE_GENERIC_CLOUD = "https://management.azure.com"
AZURE_GENERIC_GRAPH_HOST = "https://graph.microsoft.com"
AZURE_CHINA_GRAPH_HOST = "https://microsoftgraph.chinacloudapi.cn"
AZURE_US_GOV_GRAPH_HOST = "https://graph.microsoft.us"
AZURE_GENERIC_GRAPH_SCOPE = f"{AZURE_GENERIC_GRAPH_HOST}/.default"
AZURE_CHINA_GRAPH_SCOPE = f"{AZURE_CHINA_GRAPH_HOST}/.default"
AZURE_US_GOV_GRAPH_SCOPE = f"{AZURE_US_GOV_GRAPH_HOST}/.default"
AZURE_GENERIC_LOGS_ENDPOINT = "https://api.loganalytics.io"
AZURE_CHINA_LOGS_ENDPOINT = "https://api.loganalytics.azure.cn"
AZURE_US_GOV_LOGS_ENDPOINT = "https://api.loganalytics.us"
def get_regions_config(region):
allowed_regions = {
@@ -11,16 +23,25 @@ def get_regions_config(region):
"authority": None,
"base_url": AZURE_GENERIC_CLOUD,
"credential_scopes": [AZURE_GENERIC_CLOUD + "/.default"],
"graph_host": AZURE_GENERIC_GRAPH_HOST,
"graph_scope": AZURE_GENERIC_GRAPH_SCOPE,
"logs_endpoint": AZURE_GENERIC_LOGS_ENDPOINT,
},
"AzureChinaCloud": {
"authority": AzureAuthorityHosts.AZURE_CHINA,
"base_url": AZURE_CHINA_CLOUD,
"credential_scopes": [AZURE_CHINA_CLOUD + "/.default"],
"graph_host": AZURE_CHINA_GRAPH_HOST,
"graph_scope": AZURE_CHINA_GRAPH_SCOPE,
"logs_endpoint": AZURE_CHINA_LOGS_ENDPOINT,
},
"AzureUSGovernment": {
"authority": AzureAuthorityHosts.AZURE_GOVERNMENT,
"base_url": AZURE_US_GOV_CLOUD,
"credential_scopes": [AZURE_US_GOV_CLOUD + "/.default"],
"graph_host": AZURE_US_GOV_GRAPH_HOST,
"graph_scope": AZURE_US_GOV_GRAPH_SCOPE,
"logs_endpoint": AZURE_US_GOV_LOGS_ENDPOINT,
},
}
return allowed_regions[region]
+30 -2
View File
@@ -1,5 +1,11 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
from kiota_authentication_azure.azure_identity_authentication_provider import (
AzureIdentityAuthenticationProvider,
)
from msgraph.graph_request_adapter import GraphRequestAdapter
from msgraph_core import GraphClientFactory
from prowler.lib.logger import logger
from prowler.providers.azure.azure_provider import AzureProvider
@@ -47,10 +53,32 @@ class AzureService:
clients = {}
try:
if "GraphServiceClient" in str(service):
clients.update({identity.tenant_domain: service(credentials=session)})
# GraphServiceClient(credentials, scopes=...) only customises the
# OAuth scope; the underlying httpx client's base URL stays at
# graph.microsoft.com. For sovereign clouds we must also point
# the HTTP transport at the per-cloud host, which is done by
# building a custom GraphRequestAdapter with a NationalClouds
# base URL.
auth_provider = AzureIdentityAuthenticationProvider(
session, scopes=[region_config.graph_scope]
)
http_client = GraphClientFactory.create_with_default_middleware(
host=region_config.graph_host
)
request_adapter = GraphRequestAdapter(auth_provider, client=http_client)
clients.update(
{identity.tenant_domain: service(request_adapter=request_adapter)}
)
elif "LogsQueryClient" in str(service):
for subscription_id, display_name in identity.subscriptions.items():
clients.update({subscription_id: service(credential=session)})
clients.update(
{
subscription_id: service(
credential=session,
endpoint=region_config.logs_endpoint,
)
}
)
else:
for subscription_id, display_name in identity.subscriptions.items():
clients.update(
+3
View File
@@ -20,6 +20,9 @@ class AzureRegionConfig(BaseModel):
authority: Optional[str] = None
base_url: str = ""
credential_scopes: list = []
graph_host: str = "https://graph.microsoft.com"
graph_scope: str = "https://graph.microsoft.com/.default"
logs_endpoint: str = "https://api.loganalytics.io"
class AzureSubscription(BaseModel):
@@ -13,8 +13,8 @@
"Risk": "When external Google Groups access is enabled, users can access and participate in groups created **outside the organization**, potentially exposing them to **phishing, social engineering, or data leakage** through unmanaged external group communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/181865",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/users/advanced/turn-on-or-off-additional-google-services",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,9 +13,8 @@
"Risk": "Without external invitation warnings, users may unintentionally include **external guests** in internal meetings, exposing **confidential meeting details**, agendas, and internal attendee lists to unauthorized parties. This is a common vector for inadvertent data leakage through everyday calendar actions.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6329284",
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/calendar/allow-external-invitations-in-google-calendar-events",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,9 +13,8 @@
"Risk": "Overly permissive external sharing of primary calendars exposes **sensitive meeting metadata** — titles, attendees, locations, and descriptions — to users outside the organization. This increases the risk of **information disclosure**, **social engineering**, and **targeted phishing** based on insights into organizational activities.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60765",
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,9 +13,8 @@
"Risk": "Overly permissive external sharing of secondary calendars exposes **project-specific or team-specific event details** to users outside the organization. Because secondary calendars often hold more targeted activities (e.g., product launches, internal reviews), unrestricted external sharing increases the risk of **information disclosure** and **competitive intelligence leakage**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60765",
"https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted Chat app installation allows **unvetted third-party applications** to access user data including conversation content and organizational information. An attacker could distribute a malicious Chat app to **exfiltrate confidential data** or establish **persistent access** to internal communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6089179",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Enabled external file sharing allows users to send files containing **confidential information** to external parties through Chat. This creates a **data leakage** channel that bypasses DLP controls, particularly dangerous for organizations handling **regulated data** such as PII, PHI, or financial records.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted external messaging allows users to communicate freely with **any external party**, increasing the risk of **data exfiltration** through conversation content and **social engineering attacks** from untrusted domains targeting internal users.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted external spaces allow users to add **anyone from any domain** to persistent group conversations. This increases the risk of **confidential information exposure** in shared spaces and enables **unauthorized external access** to ongoing organizational discussions.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Exposed webhook URLs allow **unauthorized content injection** into Chat spaces. Attackers can send **fraudulent or misleading messages** that appear to come from trusted services, creating a vector for **social engineering** and **phishing** within internal communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6089179",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Unrestricted internal file sharing in Chat allows files with **sensitive information** to be distributed freely without passing through approved channels. This undermines **data governance** and **audit trail** requirements, making it harder to track data movement within the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9540647",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/chat/set-up-chat-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles",
"https://support.google.com/a/answer/9011373"
"https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles",
"https://support.google.com/a/answer/9011373"
"https://knowledge.workspace.google.com/admin/users/security-best-practices-for-administrator-accounts"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If Access Checker suggests broader audiences or public visibility, users may **inadvertently widen access** to a file beyond the people they intended to share with. This is a common cause of unintentional internal or external over-sharing.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When Drive for desktop is enabled, organizational files are **synchronized to local devices** and remain accessible if the device is lost, stolen, or compromised. Because Drive for desktop bypasses the central offline-access controls, this channel is a frequently overlooked path for sensitive data to leave organization-managed environments.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7491144",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/set-up-drive-for-desktop-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without external sharing warnings, users may unintentionally share **sensitive documents** with external recipients who are not entitled to the data. This is a common vector for inadvertent leakage of intellectual property, personally identifiable information, and confidential business data through routine Drive sharing.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If external users can move files from internal shared drives into shared drives owned by another organization, the organization **loses authoritative control** over its own data. This is a frequently overlooked path for unintentional or malicious data exfiltration through shared drive collaboration.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing users to publish Drive files to the web creates a path for **unbounded data exposure**. Sensitive documents, intellectual property, customer data, or internal communications can be made publicly accessible — and indexed by search engines — with a single click, often unintentionally.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When users cannot create shared drives, they store collaborative content in their personal **My Drive** instead. When that user account is deleted, the data is also deleted, leading to **unintentional data loss** of organizationally significant information. Allowing shared drive creation makes data survivable across account lifecycle events.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7212025",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://support.google.com/a/users/answer/7212025",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When viewers and commenters can download, print, or copy shared drive files, they can **bulk-extract sensitive content** — including intellectual property, personally identifiable information, and confidential business documents — using nothing more than read access. This is one of the most direct paths to data exfiltration through Drive.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7662202",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If shared drive managers can override organizational defaults, **unauthorized data exposure** can occur when a manager intentionally or accidentally weakens a shared drive's security posture (for example, allowing external members or enabling download for viewers).",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7662202",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If non-members can be added to files inside a shared drive, the **drive's membership becomes meaningless** as a security control. Sensitive content scoped to a specific team can be silently extended to users who were never granted access to the drive itself, leading to unintended information disclosure.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7662202",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-shared-drives-as-an-admin",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When external sharing is unrestricted, users can share organizational content with **any external Google account**, including untrusted or unknown parties. Restricting sharing to allowlisted domains drastically reduces the surface area for accidental and malicious data exfiltration through Drive.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowlisted domains are still external. Users may not realize that even an allowlisted recipient is outside the organization, leading to **unintentional disclosure of sensitive content** to legitimate but external collaborators. A warning prompt at share time mitigates that without preventing the sharing itself.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/60781",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/drive/manage-external-sharing-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against anomalous attachment types, users may receive **emails with unusual file formats** that are designed to bypass standard security filters. Attackers may use **uncommon file extensions or MIME types** to deliver malware that evades signature-based detection.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "With auto-forwarding enabled, an attacker who gains control of a user account can create **forwarding rules to exfiltrate** all incoming email to an external address. This can persist undetected and provide the attacker with continuous access to sensitive communications even after the account is recovered.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/2491924",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/let-users-automatically-forward-their-own-gmail-emails",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without comprehensive mail storage, messages sent through other Google services (Calendar, Drive, etc.) may not be stored in Gmail and therefore **not subject to Vault retention policies**. This creates gaps in **compliance coverage**, **eDiscovery**, and **audit trails** that could violate regulatory requirements.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/3547347",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-comprehensive-mail-storage",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against domain spoofing based on similar domain names, users may receive **phishing emails from lookalike domains** (e.g., examp1e.com instead of example.com) that appear legitimate. This enables **credential theft, malware delivery, and business email compromise** attacks.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against employee name spoofing, users may receive **emails that appear to come from colleagues or executives** but are actually from external attackers. This enables **business email compromise (BEC)**, **wire fraud**, and **social engineering attacks** that exploit trust relationships.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against encrypted attachments from untrusted senders, users may receive **password-protected archives containing malware** that bypass standard content scanning. Attackers commonly use encrypted attachments to evade detection and deliver **ransomware, trojans, or other malicious payloads**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without enhanced pre-delivery scanning, some **sophisticated phishing and malware** messages may pass through standard filters and be delivered to users. The additional scanning layer catches threats that the first-pass filters miss, reducing the organization's exposure to **zero-day phishing campaigns** and **targeted attacks**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7380368",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/security/help-prevent-phishing-with-pre-delivery-message-scanning",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without external image scanning, attackers can use **linked images to track email opens**, deliver **exploit payloads via image rendering vulnerabilities**, or use images as part of sophisticated **phishing schemes** that mimic legitimate communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection of groups from domain-spoofing emails, attackers can send **spoofed messages to group mailboxes** that appear to originate from the organization. Since groups distribute to many recipients, a single spoofed email can enable **mass phishing, social engineering, or misinformation** campaigns across the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against inbound domain spoofing, users may receive **emails that appear to come from their own organization** but are sent by external attackers. This enables **internal impersonation**, **phishing**, and **business email compromise** attacks that exploit trust in internal communications.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "If users can delegate access to their mailbox, an attacker who compromises one account could silently delegate access to maintain persistent email surveillance. This also increases the risk of **insider threats** and **data exfiltration** through shared mailbox access.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7223765",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/let-users-delegate-access-to-a-gmail-account",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "With per-user outbound gateways enabled, users can route outbound email through **external SMTP servers**, bypassing organizational **email security controls**, **DLP policies**, and **audit logging**. This creates an unmonitored channel for data exfiltration and policy circumvention.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/176652",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/allow-per-user-outbound-gateways",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "With POP and IMAP enabled, users can access email through **legacy clients** that rely on simple password authentication, bypassing **multifactor authentication** and other modern security controls. This significantly increases the risk of **credential-based account compromise**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/105694",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/sync/turn-pop-and-imap-on-or-off-for-users",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against script-bearing attachments from untrusted senders, users may receive **files containing malicious scripts** that can execute harmful code when opened. Attackers commonly use script attachments to deliver **malware, backdoors, or credential stealers**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/set-up-rules-to-detect-harmful-attachments",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without shortened URL scanning, attackers can use **URL shortening services** to hide malicious destinations in phishing emails. Users cannot visually verify where the link leads, increasing the success rate of **phishing and credential harvesting** attacks.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without protection against unauthenticated emails, users may receive **spoofed or forged messages** that fail SPF and DKIM checks but are still delivered normally. This enables **phishing**, **spam**, and **impersonation attacks** that exploit the lack of sender verification.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/9157861",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Without untrusted link warnings, users may click on **phishing links** or links to **malware distribution sites** without any warning. This significantly increases the success rate of **social engineering attacks** targeting the organization.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/7676854",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/gmail/advanced/advanced-phishing-and-malware-protection",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing any user to create groups with external members or incoming email from outside increases the risk of **unauthorized data sharing**, **spam delivery**, and **shadow IT** groups that bypass organizational controls.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing external access to groups exposes **group names, descriptions, and membership** to anyone outside the organization, increasing the risk of **information disclosure** and enabling external parties to identify targets for **social engineering attacks**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing all organization users or anyone to view group conversations can lead to **information disclosure** of sensitive discussions, internal decisions, and confidential data shared within groups.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/10308022",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/groups/what-you-get-with-groups-for-business",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "Allowing unrestricted Marketplace app installation exposes the organization to **unvetted third-party applications** that may request broad OAuth scopes, potentially gaining access to **sensitive organizational data** including emails, documents, and calendar events without proper security review.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/6089179",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/apps/manage-the-marketplace-app-allowlist-for-your-organization",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-your-business-with-2-step-verification",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-users-with-the-advanced-protection-program",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/about-dlp",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/apps/control-which-apps-access-google-workspace-data",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/apps/control-access-to-less-secure-apps",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/protect-google-workspace-accounts-with-security-challenges",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/enforce-and-monitor-password-requirements-for-users",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/security/set-session-length-for-google-services",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/allow-super-administrators-to-recover-their-password",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -14,7 +14,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/set-up-password-recovery-for-users",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
@@ -13,8 +13,8 @@
"Risk": "When Google Sites is enabled, users can create websites that may **inadvertently expose internal information** to external parties. These sites can be difficult to track and manage, creating potential **data leakage vectors** outside the organization's standard content management controls.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://support.google.com/a/answer/182442",
"https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
"https://knowledge.workspace.google.com/admin/users/advanced/turn-a-service-on-or-off-for-google-workspace-users",
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
],
"Remediation": {
"Code": {
+1 -1
View File
@@ -123,7 +123,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.13"
version = "5.29.0"
version = "5.29.2"
[project.scripts]
prowler = "prowler.__main__:prowler"
+32
View File
@@ -2,8 +2,10 @@ 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
@@ -300,6 +302,36 @@ 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",
@@ -725,6 +725,300 @@ class TestAzureProviderSetupIdentitySubscriptions:
}
class TestAzureProviderSovereignCloudSupport:
"""Sovereign-cloud authentication coverage across AzureCloud,
AzureChinaCloud and AzureUSGovernment for every authentication code path
Prowler exposes. Pinned to issue #8425."""
REGION_CASES = [
(
"AzureCloud",
None,
"https://management.azure.com",
["https://management.azure.com/.default"],
"https://graph.microsoft.com/.default",
"https://api.loganalytics.io",
"login.microsoftonline.com",
),
(
"AzureChinaCloud",
"login.chinacloudapi.cn",
"https://management.chinacloudapi.cn",
["https://management.chinacloudapi.cn/.default"],
"https://microsoftgraph.chinacloudapi.cn/.default",
"https://api.loganalytics.azure.cn",
"login.chinacloudapi.cn",
),
(
"AzureUSGovernment",
"login.microsoftonline.us",
"https://management.usgovcloudapi.net",
["https://management.usgovcloudapi.net/.default"],
"https://graph.microsoft.us/.default",
"https://api.loganalytics.us",
"login.microsoftonline.us",
),
]
@pytest.mark.parametrize(
"region,authority,base_url,credential_scopes,graph_scope,logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_setup_region_config_per_cloud(
self,
region,
authority,
base_url,
credential_scopes,
graph_scope,
logs_endpoint,
_login_endpoint,
):
config = AzureProvider.setup_region_config(region)
# graph_host mirrors graph_scope without the `/.default` suffix; we
# derive it here to avoid threading a separate parameter through every
# parametrized test in this class.
expected_graph_host = graph_scope.removesuffix("/.default")
assert config == AzureRegionConfig(
name=region,
authority=authority,
base_url=base_url,
credential_scopes=credential_scopes,
graph_host=expected_graph_host,
graph_scope=graph_scope,
logs_endpoint=logs_endpoint,
)
@pytest.mark.parametrize(
"region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_setup_session_static_credentials_passes_authority(
self,
region,
authority,
_base_url,
_credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
with patch(
"prowler.providers.azure.azure_provider.ClientSecretCredential"
) as mock_client_secret_credential:
azure_credentials = {
"tenant_id": str(uuid4()),
"client_id": str(uuid4()),
"client_secret": "fake-secret-value",
}
region_config = AzureProvider.setup_region_config(region)
AzureProvider.setup_session(
az_cli_auth=False,
sp_env_auth=False,
browser_auth=False,
managed_identity_auth=False,
tenant_id=azure_credentials["tenant_id"],
azure_credentials=azure_credentials,
region_config=region_config,
)
mock_client_secret_credential.assert_called_once_with(
tenant_id=azure_credentials["tenant_id"],
client_id=azure_credentials["client_id"],
client_secret=azure_credentials["client_secret"],
authority=authority,
)
@pytest.mark.parametrize(
"region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_setup_session_browser_auth_passes_authority(
self,
region,
authority,
_base_url,
_credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
with patch(
"prowler.providers.azure.azure_provider.InteractiveBrowserCredential"
) as mock_interactive_browser_credential:
tenant_id = str(uuid4())
region_config = AzureProvider.setup_region_config(region)
AzureProvider.setup_session(
az_cli_auth=False,
sp_env_auth=False,
browser_auth=True,
managed_identity_auth=False,
tenant_id=tenant_id,
azure_credentials=None,
region_config=region_config,
)
mock_interactive_browser_credential.assert_called_once_with(
tenant_id=tenant_id,
authority=authority,
)
@pytest.mark.parametrize(
"region,authority,_base_url,_credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_setup_session_default_credential_passes_authority(
self,
region,
authority,
_base_url,
_credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
with patch(
"prowler.providers.azure.azure_provider.DefaultAzureCredential"
) as mock_default_credential:
region_config = AzureProvider.setup_region_config(region)
AzureProvider.setup_session(
az_cli_auth=True,
sp_env_auth=False,
browser_auth=False,
managed_identity_auth=False,
tenant_id=None,
azure_credentials=None,
region_config=region_config,
)
_, called_kwargs = mock_default_credential.call_args
assert called_kwargs["authority"] == authority
assert called_kwargs["exclude_cli_credential"] is False
assert called_kwargs["exclude_environment_credential"] is True
assert called_kwargs["exclude_managed_identity_credential"] is True
@pytest.mark.parametrize(
"region,_authority,_base_url,_credential_scopes,graph_scope,_logs_endpoint,login_endpoint",
REGION_CASES,
)
def test_verify_client_uses_per_cloud_endpoints(
self,
region,
_authority,
_base_url,
_credential_scopes,
graph_scope,
_logs_endpoint,
login_endpoint,
):
tenant_id = str(uuid4())
client_id = str(uuid4())
client_secret = "fake-secret"
region_config = AzureProvider.setup_region_config(region)
with patch("prowler.providers.azure.azure_provider.requests.post") as mock_post:
mock_post.return_value = MagicMock()
mock_post.return_value.json.return_value = {"access_token": "fake-token"}
AzureProvider.verify_client(
tenant_id, client_id, client_secret, region_config
)
mock_post.assert_called_once()
args, kwargs = mock_post.call_args
assert args[0] == (
f"https://{login_endpoint}/{tenant_id}/oauth2/v2.0/token"
)
assert kwargs["data"]["scope"] == graph_scope
assert kwargs["data"]["client_id"] == client_id
assert kwargs["data"]["client_secret"] == client_secret
@pytest.mark.parametrize(
"region,_authority,base_url,credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_test_connection_passes_base_url_to_subscription_client(
self,
region,
_authority,
base_url,
credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
subscription_client_instance = MagicMock()
subscription_client_instance.subscriptions = MagicMock()
subscription_client_instance.subscriptions.list = MagicMock(return_value=[])
subscription_client_class = MagicMock(return_value=subscription_client_instance)
with (
patch(
"prowler.providers.azure.azure_provider.AzureProvider.setup_session"
) as mock_setup_session,
patch(
"prowler.providers.azure.azure_provider.SubscriptionClient",
subscription_client_class,
),
):
mock_setup_session.return_value = MagicMock()
AzureProvider.test_connection(
az_cli_auth=True,
region=region,
raise_on_exception=False,
)
subscription_client_class.assert_called_once()
_, kwargs = subscription_client_class.call_args
assert kwargs["base_url"] == base_url
assert kwargs["credential_scopes"] == credential_scopes
@pytest.mark.parametrize(
"region,_authority,base_url,credential_scopes,_graph_scope,_logs_endpoint,_login_endpoint",
REGION_CASES,
)
def test_get_locations_passes_base_url_to_subscription_client(
self,
region,
_authority,
base_url,
credential_scopes,
_graph_scope,
_logs_endpoint,
_login_endpoint,
):
subscription_client_instance = MagicMock()
subscription_client_instance.subscriptions = MagicMock()
subscription_client_instance.subscriptions.list_locations = MagicMock(
return_value=[]
)
subscription_client_class = MagicMock(return_value=subscription_client_instance)
with (
patch.object(AzureProvider, "__init__", return_value=None),
patch(
"prowler.providers.azure.azure_provider.SubscriptionClient",
subscription_client_class,
),
):
azure_provider = AzureProvider()
azure_provider._session = MagicMock()
azure_provider._region_config = AzureProvider.setup_region_config(region)
azure_provider._identity = AzureIdentityInfo(subscriptions={})
azure_provider.get_locations()
subscription_client_class.assert_called_once()
_, kwargs = subscription_client_class.call_args
assert kwargs["base_url"] == base_url
assert kwargs["credential_scopes"] == credential_scopes
class TestAzureProviderSetupIdentityEventLoop:
"""Regression for the Celery worker scenario where
asyncio.get_event_loop() raised "There is no current event loop in
@@ -2,8 +2,17 @@ from azure.identity import AzureAuthorityHosts
from prowler.providers.azure.lib.regions.regions import (
AZURE_CHINA_CLOUD,
AZURE_CHINA_GRAPH_HOST,
AZURE_CHINA_GRAPH_SCOPE,
AZURE_CHINA_LOGS_ENDPOINT,
AZURE_GENERIC_CLOUD,
AZURE_GENERIC_GRAPH_HOST,
AZURE_GENERIC_GRAPH_SCOPE,
AZURE_GENERIC_LOGS_ENDPOINT,
AZURE_US_GOV_CLOUD,
AZURE_US_GOV_GRAPH_HOST,
AZURE_US_GOV_GRAPH_SCOPE,
AZURE_US_GOV_LOGS_ENDPOINT,
get_regions_config,
)
@@ -20,16 +29,25 @@ class Test_azure_regions:
"authority": None,
"base_url": AZURE_GENERIC_CLOUD,
"credential_scopes": [AZURE_GENERIC_CLOUD + "/.default"],
"graph_host": AZURE_GENERIC_GRAPH_HOST,
"graph_scope": AZURE_GENERIC_GRAPH_SCOPE,
"logs_endpoint": AZURE_GENERIC_LOGS_ENDPOINT,
},
"AzureChinaCloud": {
"authority": AzureAuthorityHosts.AZURE_CHINA,
"base_url": AZURE_CHINA_CLOUD,
"credential_scopes": [AZURE_CHINA_CLOUD + "/.default"],
"graph_host": AZURE_CHINA_GRAPH_HOST,
"graph_scope": AZURE_CHINA_GRAPH_SCOPE,
"logs_endpoint": AZURE_CHINA_LOGS_ENDPOINT,
},
"AzureUSGovernment": {
"authority": AzureAuthorityHosts.AZURE_GOVERNMENT,
"base_url": AZURE_US_GOV_CLOUD,
"credential_scopes": [AZURE_US_GOV_CLOUD + "/.default"],
"graph_host": AZURE_US_GOV_GRAPH_HOST,
"graph_scope": AZURE_US_GOV_GRAPH_SCOPE,
"logs_endpoint": AZURE_US_GOV_LOGS_ENDPOINT,
},
}
@@ -0,0 +1,108 @@
from unittest.mock import MagicMock, patch
import pytest
from prowler.providers.azure.lib.service.service import AzureService
from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig
REGION_CASES = [
(
"AzureCloud",
"https://graph.microsoft.com",
"https://graph.microsoft.com/.default",
"https://api.loganalytics.io",
),
(
"AzureChinaCloud",
"https://microsoftgraph.chinacloudapi.cn",
"https://microsoftgraph.chinacloudapi.cn/.default",
"https://api.loganalytics.azure.cn",
),
(
"AzureUSGovernment",
"https://graph.microsoft.us",
"https://graph.microsoft.us/.default",
"https://api.loganalytics.us",
),
]
def _identity_and_session():
identity = AzureIdentityInfo(
tenant_domain="tenant.onmicrosoft.com",
subscriptions={"sub-1": "Subscription 1"},
)
session = MagicMock()
return identity, session
class TestAzureServiceSovereignClouds:
"""Cover __set_clients__ kwargs for the Graph and Logs clients across the
three sovereign clouds these are the two service slots in service.py
that historically defaulted to public-cloud endpoints."""
@pytest.mark.parametrize(
"_region,graph_host,graph_scope,_logs_endpoint",
REGION_CASES,
)
def test_set_clients_graph_uses_per_cloud_host_scope_and_adapter(
self, _region, graph_host, graph_scope, _logs_endpoint
):
graph_service = MagicMock()
graph_service.__str__ = MagicMock(return_value="GraphServiceClient")
region_config = AzureRegionConfig(
graph_host=graph_host,
graph_scope=graph_scope,
logs_endpoint=_logs_endpoint,
)
identity, session = _identity_and_session()
with (
patch.object(AzureService, "__init__", return_value=None),
patch(
"prowler.providers.azure.lib.service.service.AzureIdentityAuthenticationProvider"
) as mock_auth_provider_cls,
patch(
"prowler.providers.azure.lib.service.service.GraphClientFactory"
) as mock_factory,
patch(
"prowler.providers.azure.lib.service.service.GraphRequestAdapter"
) as mock_adapter_cls,
):
service = AzureService.__new__(AzureService)
service.__set_clients__(identity, session, graph_service, region_config)
mock_auth_provider_cls.assert_called_once_with(session, scopes=[graph_scope])
mock_factory.create_with_default_middleware.assert_called_once_with(
host=graph_host
)
mock_adapter_cls.assert_called_once_with(
mock_auth_provider_cls.return_value,
client=mock_factory.create_with_default_middleware.return_value,
)
graph_service.assert_called_once_with(
request_adapter=mock_adapter_cls.return_value
)
@pytest.mark.parametrize(
"_region,_graph_host,_graph_scope,logs_endpoint",
REGION_CASES,
)
def test_set_clients_logs_passes_per_cloud_endpoint(
self, _region, _graph_host, _graph_scope, logs_endpoint
):
logs_service = MagicMock()
logs_service.__str__ = MagicMock(return_value="LogsQueryClient")
region_config = AzureRegionConfig(
graph_host=_graph_host,
graph_scope=_graph_scope,
logs_endpoint=logs_endpoint,
)
identity, session = _identity_and_session()
with patch.object(AzureService, "__init__", return_value=None):
service = AzureService.__new__(AzureService)
service.__set_clients__(identity, session, logs_service, region_config)
logs_service.assert_called_once_with(credential=session, endpoint=logs_endpoint)
+19 -2
View File
@@ -2,11 +2,28 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.29.0] (Prowler UNRELEASED)
## [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
- New Scan Jobs view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258)
- Restyle `Scan Jobs` view with specific In Progress, Completed, Scheduled tabs [(#11258)](https://github.com/prowler-cloud/prowler/pull/11258)
### 🔄 Changed
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } 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>{children}</div>
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -220,4 +220,45 @@ 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,26 +1,12 @@
"use client";
import { useSearchParams } from "next/navigation";
import { ReactNode, useState } from "react";
import { useState } from "react";
import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
ProviderTypeIcon,
ProviderTypeIconStack,
} from "@/components/icons/providers-badge/provider-type-icon";
import { Badge } from "@/components/shadcn";
import {
MultiSelect,
@@ -45,25 +31,6 @@ 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[];
@@ -158,10 +125,36 @@ 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="truncate">{name}</span>;
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>
);
}
// 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="truncate">{selectedIds.length} Providers selected</span>
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIconStack items={items} />
<span className="truncate">
{selectedIds.length} Providers selected
</span>
</span>
);
};
@@ -208,7 +201,6 @@ 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,
@@ -228,7 +220,9 @@ export function AccountsSelector({
if (closeOnSelect) setSelectorOpen(false);
}}
>
<span aria-hidden="true">{icon}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</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 } from "@testing-library/react";
import { render, screen, within } 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>{children}</div>
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -145,4 +145,26 @@ 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,8 +1,12 @@
"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,
@@ -14,163 +18,6 @@ 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[];
@@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({
.map((p) => p.attributes.provider),
),
)
.filter((type): type is ProviderType => type in PROVIDER_DATA)
.filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
.sort((a, b) =>
PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_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">
{renderIcon(providerType)}
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</span>
<span className="truncate">
{PROVIDER_TYPE_DATA[providerType].label}
</span>
</span>
);
}
return (
<span className="min-w-0 truncate">
{selectedTypes.length} Provider Types selected
<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>
);
};
@@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({
<MultiSelectItem
key={providerType}
value={providerType}
badgeLabel={PROVIDER_DATA[providerType].label}
keywords={[providerType, PROVIDER_DATA[providerType].label]}
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
keywords={[
providerType,
PROVIDER_TYPE_DATA[providerType].label,
]}
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
>
<span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} size={24} />
</span>
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
</MultiSelectItem>
))}
</>
+9
View File
@@ -34,4 +34,13 @@ describe("providers page", () => {
expect(source).toContain("size: 160");
expect(source).toContain("size: 140");
});
it("keeps the CLI import banner gated by the Cloud environment", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV");
expect(source).toContain("{isCloudEnvironment && <CliImportBanner");
});
});
+3
View File
@@ -2,6 +2,7 @@ import { Suspense } from "react";
import { ProvidersAccountsView } from "@/components/providers";
import { SkeletonTableProviders } from "@/components/providers/table";
import { CliImportBanner } from "@/components/scans";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
@@ -19,6 +20,7 @@ export default async function Providers({
}) {
const resolvedSearchParams = await searchParams;
const activeTab = getProviderTab(resolvedSearchParams.tab);
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
// Exclude `tab` from the Suspense key so switching tabs doesn't re-suspend
const { tab: _, ...paramsWithoutTab } = resolvedSearchParams || {};
@@ -26,6 +28,7 @@ export default async function Providers({
return (
<ContentLayout title="Providers" icon="lucide:cloud-cog">
{isCloudEnvironment && <CliImportBanner className="mb-6" />}
<FilterTransitionWrapper>
<ProviderPageTabs
activeTab={activeTab}
+3
View File
@@ -109,6 +109,9 @@ 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,
};
});
@@ -0,0 +1,45 @@
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,43 +1,10 @@
import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OktaProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
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;
@@ -49,9 +16,9 @@ export const ProviderIconCell = ({
size = 26,
className = "size-8 rounded-md bg-white",
}: ProviderIconCellProps) => {
const IconComponent = PROVIDER_ICONS[provider];
if (!IconComponent) {
// 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)) {
return (
<div className={cn("flex items-center justify-center", className)}>
<span className="text-text-neutral-secondary text-xs">?</span>
@@ -66,7 +33,7 @@ export const ProviderIconCell = ({
className,
)}
>
<IconComponent width={size} height={size} />
<ProviderTypeIcon type={provider} size={size} />
</div>
);
};
@@ -0,0 +1,126 @@
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();
});
});
@@ -0,0 +1,250 @@
"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,7 +8,10 @@ import { useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
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";
@@ -279,11 +282,14 @@ export const S3IntegrationForm = ({
// Show configuration step (step 0 or editing configuration)
if (isEditingConfig || currentStep === 0) {
const providerOptions = providers.map((provider) => {
const Icon = PROVIDER_ICONS[provider.attributes.provider];
const providerType = provider.attributes.provider;
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
description: provider.attributes.connection.connected
? "Connected"
: "Disconnected",
@@ -10,7 +10,10 @@ import { useEffect, useState } from "react";
import { Control, useForm } from "react-hook-form";
import { createIntegration, updateIntegration } from "@/actions/integrations";
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
import {
PROVIDER_TYPE_DATA,
ProviderTypeIcon,
} from "@/components/icons/providers-badge/provider-type-icon";
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";
@@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({
? "Connected"
: "Disconnected";
const Icon = PROVIDER_ICONS[provider.attributes.provider];
const providerType = provider.attributes.provider;
return {
value: provider.id,
label: provider.attributes.alias || provider.attributes.uid,
icon: Icon ? <Icon width={20} height={20} /> : undefined,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
description: isDisabled
? `${connectionLabel} (Already in use)`
: connectionLabel,
@@ -0,0 +1,92 @@
"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,11 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { afterEach, 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: () => <button type="button">Add provider</button>,
AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => (
<button type="button" onClick={onOpenWizard}>
Add Provider
</button>
),
}));
vi.mock("@/components/providers/muted-findings-config-button", () => ({
@@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
}));
vi.mock("@/components/providers/providers-filters", () => ({
ProvidersFilters: () => <div data-testid="providers-filters">Filters</div>,
ProvidersFilters: ({ actions }: { actions: ReactNode }) => (
<div data-testid="providers-filters">
Filters
{actions}
</div>
),
}));
vi.mock("@/components/providers/providers-accounts-table", () => ({
@@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
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,
}));
import { ProvidersAccountsView } from "./providers-accounts-view";
@@ -36,8 +80,55 @@ 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", () => {
it("keeps the same vertical spacing between filters and table as other views", () => {
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
render(
<ProvidersAccountsView
isCloud={false}
@@ -48,11 +139,170 @@ 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,9 +1,11 @@
"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";
@@ -11,6 +13,10 @@ 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";
@@ -29,7 +35,14 @@ export function ProvidersAccountsView({
providers,
rows,
}: ProvidersAccountsViewProps) {
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
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 [providerWizardInitialData, setProviderWizardInitialData] = useState<
ProviderWizardInitialData | undefined
>(undefined);
@@ -52,38 +65,64 @@ export function ProvidersAccountsView({
const handleWizardOpenChange = (open: boolean) => {
setIsProviderWizardOpen(open);
if (!open) {
setProviderWizardInitialData(undefined);
setOrgWizardInitialData(undefined);
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,
);
}
};
return (
<>
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
{hasNoProviders ? (
<NoProvidersAdded
action="button"
containerClassName="min-h-[calc(100dvh-28rem)]"
onOpenWizard={() => openProviderWizard()}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
) : (
<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>
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={handleWizardOpenChange}
initialData={providerWizardInitialData}
orgInitialData={orgWizardInitialData}
refreshOnClose={false}
/>
</>
);
@@ -50,6 +50,10 @@ 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({
@@ -57,6 +61,7 @@ export function useProviderWizardController({
onOpenChange,
initialData,
orgInitialData,
refreshOnClose = true,
}: UseProviderWizardControllerProps) {
const router = useRouter();
const initialProviderId = initialData?.providerId ?? null;
@@ -185,7 +190,9 @@ export function useProviderWizardController({
setProviderTypeHint(null);
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
onOpenChange(false);
router.refresh();
if (refreshOnClose) {
router.refresh();
}
};
const handleDialogOpenChange = (nextOpen: boolean) => {

Some files were not shown because too many files have changed in this diff Show More