Compare commits

...

14 Commits

Author SHA1 Message Date
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
105 changed files with 1675 additions and 211 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.1
# 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.1"
[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.1
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.1"
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:
+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.1"
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.1"
[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)
+2 -2
View File
@@ -2,11 +2,11 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.29.0] (Prowler UNRELEASED)
## [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
+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,6 +3,7 @@ import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SCAN_JOBS_TAB } from "@/types";
import { LaunchStep } from "./launch-step";
@@ -81,5 +82,9 @@ describe("LaunchStep", () => {
title: "Scan Launched",
}),
);
const toastPayload = toastMock.mock.calls[0]?.[0];
expect(toastPayload.action.props.children.props.href).toBe(
`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`,
);
});
});
@@ -15,6 +15,7 @@ import { Spinner } from "@/components/shadcn/spinner/spinner";
import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon";
import { ToastAction, useToast } from "@/components/ui";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { SCAN_JOBS_TAB } from "@/types";
import { TREE_ITEM_STATUS } from "@/types/tree";
import {
@@ -81,7 +82,7 @@ export function LaunchStep({
: "Single scan launched successfully.",
action: (
<ToastAction altText="Go to scans" asChild>
<Link href="/scans">Go to scans</Link>
<Link href={`/scans?tab=${SCAN_JOBS_TAB.ACTIVE}`}>Go to scans</Link>
</ToastAction>
),
});
@@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DOCS_URLS } from "@/lib/external-urls";
import { CliImportBanner } from "./cli-import-banner";
const STORAGE_KEY = "prowler:cli-import-banner-dismissed";
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});
describe("CliImportBanner", () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});
it("renders the banner when not dismissed", () => {
render(<CliImportBanner />);
expect(
screen.getByText(/Import findings from Prowler CLI/),
).toBeInTheDocument();
});
it("renders a link to the documentation", () => {
render(<CliImportBanner />);
const link = screen.getByRole("link", { name: "Learn more" });
expect(link).toHaveAttribute("href", DOCS_URLS.FINDINGS_INGESTION);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("does not render when previously dismissed", () => {
localStorageMock.setItem(STORAGE_KEY, "true");
const { container } = render(<CliImportBanner />);
expect(container).toBeEmptyDOMElement();
});
it("dismisses the banner and persists to localStorage on close", async () => {
const user = userEvent.setup();
render(<CliImportBanner />);
const closeButton = screen.getByRole("button", { name: "Close" });
await user.click(closeButton);
expect(
screen.queryByText(/Import findings from Prowler CLI/),
).not.toBeInTheDocument();
expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true");
});
it("renders with role='alert'", () => {
render(<CliImportBanner />);
expect(screen.getByRole("alert")).toBeInTheDocument();
});
});
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { Upload } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Alert, AlertTitle } from "@/components/shadcn";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { DOCS_URLS } from "@/lib/external-urls";
import { cn } from "@/lib/utils";
const STORAGE_KEY = "prowler:cli-import-banner-dismissed";
export const CliImportBanner = ({ className }: { className?: string }) => {
const [isVisible, setIsVisible] = useState<boolean | null>(null);
useMountEffect(() => {
const isDismissed = localStorage.getItem(STORAGE_KEY) === "true";
setIsVisible(!isDismissed);
});
const handleClose = () => {
localStorage.setItem(STORAGE_KEY, "true");
setIsVisible(false);
};
if (isVisible === null || !isVisible) return null;
return (
<Alert
variant="info"
onClose={handleClose}
className={cn("animate-fade-in", className)}
>
<Upload />
<AlertTitle>
Import findings from Prowler CLI {" "}
<Link
href={DOCS_URLS.FINDINGS_INGESTION}
target="_blank"
rel="noopener noreferrer"
className="font-normal underline underline-offset-2"
>
Learn more
</Link>
</AlertTitle>
</Alert>
);
};
+1
View File
@@ -1 +1,2 @@
export * from "./auto-refresh";
export * from "./cli-import-banner";
@@ -0,0 +1,66 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SCAN_JOBS_TAB } from "@/types";
import { ScansFilterBar } from "./scans-filter-bar";
vi.mock("@/components/filters/provider-account-selectors", () => ({
ProviderAccountSelectors: () => <div>Provider account selectors</div>,
}));
vi.mock("@/components/shadcn", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => <div data-value={value}>{children}</div>,
SelectTrigger: ({ children, ...props }: React.ComponentProps<"button">) => (
<button {...props}>{children}</button>
),
SelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
),
}));
const defaultProps = {
providers: [],
scheduleType: "all",
scanStatus: "all",
showStatusFilter: false,
onScheduleTypeChange: vi.fn(),
onScanStatusChange: vi.fn(),
};
describe("ScansFilterBar", () => {
it("hides the type filter on the scheduled tab", () => {
// Given
render(
<ScansFilterBar {...defaultProps} activeTab={SCAN_JOBS_TAB.SCHEDULED} />,
);
// Then
expect(
screen.queryByRole("button", { name: /all types/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Provider account selectors")).toBeInTheDocument();
});
it("shows the type filter outside the scheduled tab", () => {
// Given
render(
<ScansFilterBar {...defaultProps} activeTab={SCAN_JOBS_TAB.COMPLETED} />,
);
// Then
expect(screen.getByRole("button", { name: /all types/i })).toBeVisible();
});
});
+16 -13
View File
@@ -8,7 +8,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/shadcn";
import type { ScanJobsTab } from "@/types";
import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types";
import type { ProviderProps } from "@/types/providers";
import {
@@ -40,6 +40,7 @@ export function ScansFilterBar({
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment);
const statusFilterOptions = getScanStatusFilterOptions(activeTab);
const showScheduleTypeFilter = activeTab !== SCAN_JOBS_TAB.SCHEDULED;
return (
<>
@@ -52,18 +53,20 @@ export function ScansFilterBar({
accountSelectorClassName={filterItemClass}
/>
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{triggerFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{showScheduleTypeFilter && (
<Select value={scheduleType} onValueChange={onScheduleTypeChange}>
<SelectTrigger aria-label="All Types" className={filterItemClass}>
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
{triggerFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{showStatusFilter && (
<Select value={scanStatus} onValueChange={onScanStatusChange}>
@@ -16,6 +16,32 @@ const { scansFilterBarSpy } = vi.hoisted(() => ({
scansFilterBarSpy: vi.fn(),
}));
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
clear: vi.fn(() => {
store = {};
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
};
})();
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
writable: true,
});
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
@@ -120,6 +146,7 @@ describe("ScansPageShell", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
localStorageMock.clear();
searchParamsValue.current = "";
useScansStore.getState().closeLaunchScanModal();
});
@@ -191,6 +218,36 @@ describe("ScansPageShell", () => {
expect(screen.getByRole("combobox", { name: /all types/i })).toBeVisible();
});
it("shows the CLI import banner in Cloud", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
/import findings from prowler cli/i,
);
expect(screen.getByRole("link", { name: /learn more/i })).toHaveAttribute(
"href",
"https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings",
);
});
it("hides the CLI import banner outside Cloud", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps launch scan with filters and mutelist with tabs", () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
@@ -332,4 +389,22 @@ describe("ScansPageShell", () => {
expect(calledUrl).toContain("tab=active");
expect(calledUrl).not.toContain("filter%5Bstate__in%5D");
});
it("clears type filter when switching to scheduled scans", async () => {
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
searchParamsValue.current = "tab=completed&filter%5Btrigger%5D=manual";
const user = userEvent.setup();
render(
<ScansPageShell providers={providers} hasManageScansPermission>
<div>Scans table</div>
</ScansPageShell>,
);
await user.click(screen.getByRole("tab", { name: /scheduled/i }));
const calledUrl = pushMock.mock.calls.at(-1)?.[0] as string;
expect(calledUrl).toContain("tab=scheduled");
expect(calledUrl).not.toContain("filter%5Btrigger%5D");
});
});
+4
View File
@@ -19,6 +19,7 @@ import { useScansStore } from "@/store";
import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types";
import type { ProviderProps } from "@/types/providers";
import { CliImportBanner } from "./cli-import-banner";
import { LaunchScanModal } from "./launch-scan-modal";
import { ScansFilterBar } from "./scans-filter-bar";
import { useScansFilters } from "./use-scans-filters";
@@ -53,6 +54,7 @@ export function ScansPageShell({
const hasConnectedProviders = providers.some(
(provider) => provider.attributes.connection.connected === true,
);
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const launchDisabled = !hasManageScansPermission || !hasConnectedProviders;
const launchOpen = isLaunchScanModalOpen || urlLaunchOpen;
@@ -104,6 +106,8 @@ export function ScansPageShell({
</Button>
</div>
{isCloudEnvironment && <CliImportBanner />}
<Tabs
value={filters.activeTab}
onValueChange={filters.setTab}
@@ -1,9 +1,22 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
replaceMock: vi.fn(),
searchParamsValue: { current: "" },
}));
vi.mock("next/navigation", () => ({
usePathname: () => "/scans",
useRouter: () => ({
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: ({ open }: { open: boolean }) =>
open ? <div role="dialog">Provider wizard</div> : null,
@@ -14,6 +27,11 @@ vi.mock("./no-providers-connected", () => ({
}));
describe("ScansProvidersEmptyState", () => {
afterEach(() => {
vi.clearAllMocks();
searchParamsValue.current = "";
});
it("shows the add provider message and opens the provider wizard", async () => {
const user = userEvent.setup();
@@ -28,6 +46,25 @@ describe("ScansProvidersEmptyState", () => {
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("clears the launch scan URL intent before opening the provider wizard", async () => {
// Given
searchParamsValue.current = "tab=completed&launchScan=true";
const user = userEvent.setup();
render(<ScansProvidersEmptyState thereIsNoProviders />);
// When
await user.click(
screen.getByRole("button", { name: /open add provider modal/i }),
);
// Then
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
scroll: false,
});
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
it("shows the no connected providers message", () => {
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
@@ -1,8 +1,10 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { ProviderWizardModal } from "@/components/providers/wizard";
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
import { NoProvidersAdded } from "./no-providers-added";
import { NoProvidersConnected } from "./no-providers-connected";
@@ -14,12 +16,28 @@ interface ScansProvidersEmptyStateProps {
export function ScansProvidersEmptyState({
thereIsNoProviders,
}: ScansProvidersEmptyStateProps) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
const openProviderWizard = () => {
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
const params = new URLSearchParams(searchParams.toString());
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, {
scroll: false,
});
}
setIsProviderWizardOpen(true);
};
return (
<>
{thereIsNoProviders ? (
<NoProvidersAdded onOpenWizard={() => setIsProviderWizardOpen(true)} />
<NoProvidersAdded onOpenWizard={openProviderWizard} />
) : (
<NoProvidersConnected />
)}
+12
View File
@@ -94,6 +94,18 @@ describe("scans.utils", () => {
});
});
it("excludes trigger filters from scheduled scans", () => {
expect(
getScanJobsUserFilters({
tab: "scheduled",
"filter[trigger]": "manual",
"filter[provider_uid]": "123456789012",
}),
).toEqual({
"filter[provider_uid]": "123456789012",
});
});
it("formats scan labels and durations for table display", () => {
expect(getScanAlias(makeScan(""))).toBe("-");
expect(getScanAlias(makeScan("Daily scheduled scan", "scheduled"))).toBe(
+6
View File
@@ -77,8 +77,14 @@ function isSearchParamValue(value: unknown): value is string | string[] {
export function getScanJobsUserFilters(
searchParams: SearchParamsProps,
): Record<string, string | string[]> {
const tab = getScanJobsTab(searchParams.tab);
return Object.entries(searchParams).reduce<Record<string, string | string[]>>(
(filters, [key, value]) => {
if (tab === SCAN_JOBS_TAB.SCHEDULED && key === "filter[trigger]") {
return filters;
}
if (
key.startsWith("filter[") &&
!isScanStateFilterKey(key) &&
@@ -81,6 +81,17 @@ const makeCompletedScan = (): ScanProps => ({
},
});
const makeScheduledScan = (): ScanProps => ({
...makeCompletedScan(),
attributes: {
...makeCompletedScan().attributes,
trigger: "scheduled",
state: "scheduled",
scheduled_at: "2026-01-01T10:00:00Z",
next_scan_at: "2026-01-02T10:00:00Z",
},
});
const renderCell = (
columnId: string,
scan: ScanProps,
@@ -137,7 +148,6 @@ describe("getScanJobsColumns", () => {
"account",
"scanInfo",
"scanSchedule",
"nextScan",
"actions",
]);
});
@@ -163,4 +173,19 @@ describe("getScanJobsColumns", () => {
expect(screen.getByText("1 min 13 sec")).toBeInTheDocument();
});
it("labels the completed scan schedule column as Type", () => {
renderHeader(SCAN_JOBS_TAB.COMPLETED, "scanSchedule");
expect(screen.getByText("Type")).toBeInTheDocument();
expect(screen.queryByText("Schedule")).not.toBeInTheDocument();
});
it("keeps the scheduled column without repeating the scheduled label in each row", () => {
renderHeader(SCAN_JOBS_TAB.SCHEDULED, "scanSchedule");
renderCell("scanSchedule", makeScheduledScan(), SCAN_JOBS_TAB.SCHEDULED);
expect(screen.getByText("Schedule")).toBeInTheDocument();
expect(screen.queryByText("Scheduled")).not.toBeInTheDocument();
});
});
+17 -19
View File
@@ -39,13 +39,25 @@ const scanInfoColumn: ColumnDef<ScanProps> = {
cell: ({ row }) => <ScanInfoCell scan={row.original} />,
};
const scanScheduleColumn: ColumnDef<ScanProps> = {
const getScanScheduleColumn = (title: string): ColumnDef<ScanProps> => ({
id: "scanSchedule",
accessorFn: (row) => row.attributes.trigger,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Schedule" param="trigger" />
<DataTableColumnHeader column={column} title={title} param="trigger" />
),
cell: ({ row }) => <ScheduleCell scan={row.original} />,
});
const scheduledScanScheduleColumn: ColumnDef<ScanProps> = {
id: "scanSchedule",
accessorFn: (row) => row.attributes.scheduled_at,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Schedule" />
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.scheduled_at} showTime />
),
enableSorting: false,
};
const resourcesColumn: ColumnDef<ScanProps> = {
@@ -86,7 +98,7 @@ const activeColumns = (): ColumnDef<ScanProps>[] => [
cell: ({ row }) => <ProgressCell scan={row.original} />,
enableSorting: false,
},
scanScheduleColumn,
getScanScheduleColumn("Schedule"),
{
id: "launched",
header: ({ column }) => (
@@ -118,7 +130,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
cell: ({ row }) => <StatusBadge status={row.original.attributes.state} />,
enableSorting: false,
},
scanScheduleColumn,
getScanScheduleColumn("Type"),
{
id: "scanDate",
accessorFn: (row) => row.attributes.completed_at,
@@ -139,7 +151,7 @@ const completedColumns = (): ColumnDef<ScanProps>[] => [
const scheduledColumns = (): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
scanScheduleColumn,
scheduledScanScheduleColumn,
/*
* TODO: Restore this column when the API exposes the last completed scan date for this schedule.
* {
@@ -153,20 +165,6 @@ const scheduledColumns = (): ColumnDef<ScanProps>[] => [
* enableSorting: false,
* },
*/
{
id: "nextScan",
accessorFn: (row) => row.attributes.next_scan_at,
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Next Run"
param="next_scan_at"
/>
),
cell: ({ row }) => (
<DateWithTime dateTime={row.original.attributes.next_scan_at} />
),
},
actionsColumn,
];
+9 -3
View File
@@ -46,13 +46,19 @@ export function useScansFilters(): UseScansFiltersReturn {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
const setTab = (tab: string) =>
updateParams({
const setTab = (tab: string) => {
const isScheduledTab = tab === SCAN_JOBS_TAB.SCHEDULED;
const updates: Record<string, string | null> = {
tab,
sort: null,
"filter[state]": null,
"filter[state__in]": null,
});
};
if (isScheduledTab) updates["filter[trigger]"] = null;
updateParams(updates);
};
const setScheduleType = (value: string) =>
updateParams({ "filter[trigger]": value });

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