chore: merge master into PROWLER-2085 kubeconfig fix

This commit is contained in:
Hugo P.Brito
2026-07-03 12:12:36 +01:00
166 changed files with 8762 additions and 806 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.32.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.33.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+24 -22
View File
@@ -30,17 +30,18 @@ updates:
# - "pip"
# - "component/api"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 25
target-branch: master
labels:
- "dependencies"
- "github_actions"
cooldown:
default-days: 7
# Dependabot version updates disabled - migrated to Renovate - 2026/07/02
# - package-ecosystem: "github-actions"
# directory: "/"
# schedule:
# interval: "monthly"
# open-pull-requests-limit: 25
# target-branch: master
# labels:
# - "dependencies"
# - "github_actions"
# cooldown:
# default-days: 7
# Dependabot Updates are temporary disabled - 2025/03/19
# - package-ecosystem: "npm"
@@ -54,17 +55,18 @@ updates:
# - "npm"
# - "component/ui"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "monthly"
open-pull-requests-limit: 25
target-branch: master
labels:
- "dependencies"
- "docker"
cooldown:
default-days: 7
# Dependabot version updates disabled - migrated to Renovate - 2026/07/02
# - package-ecosystem: "docker"
# directory: "/"
# schedule:
# interval: "monthly"
# open-pull-requests-limit: 25
# target-branch: master
# labels:
# - "dependencies"
# - "docker"
# cooldown:
# default-days: 7
# - package-ecosystem: "pre-commit"
# directory: "/"
+3 -3
View File
@@ -38,7 +38,7 @@
"schedule": [
"* 22-23,0-5 1 * *"
],
"enabled": false
"enabled": true
},
{
"description": "Minors: 8th of every 3 months, Madrid overnight window (22:00-06:00)",
@@ -48,7 +48,7 @@
"schedule": [
"* 22-23,0-5 8 */3 *"
],
"enabled": false
"enabled": true
},
{
"description": "Majors: 15th of every 3 months, Madrid overnight window",
@@ -58,7 +58,7 @@
"schedule": [
"* 22-23,0-5 15 */3 *"
],
"enabled": false
"enabled": true
},
{
"description": "GitHub Actions - single grouped PR, no changelog, scope=ci",
+18 -12
View File
@@ -2,7 +2,24 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.33.0] (Prowler UNRELEASED)
## [1.33.1] (Prowler UNRELEASED)
### 🐞 Fixed
- Attack Paths: Scan rows now have database defaults for `is_migrated` and `sink_backend` so `scan-perform-scheduled` inserts survive deploy skew [(#11826)](https://github.com/prowler-cloud/prowler/pull/11826)
### 🔐 Security
- User profile updates now allow users to update their own account while requiring user-management permissions to update other users in the same tenant [(#11792)](https://github.com/prowler-cloud/prowler/pull/11792)
- Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, preventing user-supplied commands from running on Cloud workers [(#11753)](https://github.com/prowler-cloud/prowler/pull/11753)
---
## [1.33.0] (Prowler v5.32.0)
### 🚀 Added
- Timestamp precision support for `/api/v1/findings` `inserted_at` and `updated_at` filters [(#11754)](https://github.com/prowler-cloud/prowler/pull/11754)
### 🔄 Changed
@@ -13,17 +30,6 @@ All notable changes to the **Prowler API** are documented in this file.
### 🐞 Fixed
- Attack Paths: Provider graph cleanup now deletes Neo4j and Neptune relationships in directed batches before deleting nodes [(#11755)](https://github.com/prowler-cloud/prowler/pull/11755)
### 🔐 Security
- Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, preventing user-supplied commands from running on Cloud workers [(#11753)](https://github.com/prowler-cloud/prowler/pull/11753)
---
## [1.32.2] (Prowler UNRELEASED)
### 🐞 Fixed
- `scan-perform` no longer reports an error when a provider is deleted during a running scan [(#11696)](https://github.com/prowler-cloud/prowler/pull/11696)
---
+1 -1
View File
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.33.0"
version = "1.34.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
+206 -59
View File
@@ -67,6 +67,7 @@ from django_filters.rest_framework import (
)
from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
from rest_framework_json_api.serializers import ValidationError
from uuid6 import UUID
class CustomDjangoFilterBackend(DjangoFilterBackend):
@@ -672,35 +673,32 @@ class LatestResourceFilter(ProviderRelationshipFilterSet):
return queryset.filter(tags__text_search=value)
class FindingFilter(CommonFindingFilters):
FINDING_BASE_FILTER_FIELDS = {
"id": ["exact", "in"],
"uid": ["exact", "in"],
"scan": ["exact", "in"],
"delta": ["exact", "in"],
"status": ["exact", "in"],
"severity": ["exact", "in"],
"impact": ["exact", "in"],
"check_id": ["exact", "in", "icontains"],
}
class BaseFindingFilter(CommonFindingFilters):
DATE_FILTER_FIELDS = ()
DATE_FILTER_NAMES = ()
DATE_RANGE_HELP_TEXT = (
f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days."
)
DATE_FILTER_REQUIRED_DETAIL = "At least one date filter is required."
scan = UUIDFilter(method="filter_scan_id")
scan__in = UUIDInFilter(method="filter_scan_id_in")
inserted_at = DateFilter(method="filter_inserted_at", lookup_expr="date")
inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date")
inserted_at__gte = DateFilter(
method="filter_inserted_at_gte",
help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
)
inserted_at__lte = DateFilter(
method="filter_inserted_at_lte",
help_text=f"Maximum date range is {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
)
class Meta:
model = Finding
fields = {
"id": ["exact", "in"],
"uid": ["exact", "in"],
"scan": ["exact", "in"],
"delta": ["exact", "in"],
"status": ["exact", "in"],
"severity": ["exact", "in"],
"impact": ["exact", "in"],
"check_id": ["exact", "in", "icontains"],
"inserted_at": ["date", "gte", "lte"],
"updated_at": ["gte", "lte"],
}
fields = FINDING_BASE_FILTER_FIELDS
filter_overrides = {
FindingDeltaEnumField: {
"filter_class": CharFilter,
@@ -723,17 +721,13 @@ class FindingFilter(CommonFindingFilters):
return queryset.filter(resource_services__contains=[value])
def filter_queryset(self, queryset):
if not (self.data.get("scan") or self.data.get("scan__in")) and not (
self.data.get("inserted_at")
or self.data.get("inserted_at__date")
or self.data.get("inserted_at__gte")
or self.data.get("inserted_at__lte")
if not (self.data.get("scan") or self.data.get("scan__in")) and not any(
self.data.get(filter_name) for filter_name in self.DATE_FILTER_NAMES
):
raise ValidationError(
[
{
"detail": "At least one date filter is required: filter[inserted_at], filter[inserted_at.gte], "
"or filter[inserted_at.lte].",
"detail": self.DATE_FILTER_REQUIRED_DETAIL,
"status": 400,
"source": {"pointer": "/data/attributes/inserted_at"},
"code": "required",
@@ -742,31 +736,42 @@ class FindingFilter(CommonFindingFilters):
)
cleaned = self.form.cleaned_data
exact_date = cleaned.get("inserted_at") or cleaned.get("inserted_at__date")
gte_date = cleaned.get("inserted_at__gte") or exact_date
lte_date = cleaned.get("inserted_at__lte") or exact_date
if gte_date is None:
gte_date = datetime.now(UTC).date()
if lte_date is None:
lte_date = datetime.now(UTC).date()
if abs(lte_date - gte_date) > timedelta(
days=settings.FINDINGS_MAX_DAYS_IN_RANGE
):
raise ValidationError(
[
{
"detail": f"The date range cannot exceed {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
"status": 400,
"source": {"pointer": "/data/attributes/inserted_at"},
"code": "invalid",
}
]
)
for field_name in self.DATE_FILTER_FIELDS:
self.validate_datetime_filter_range(cleaned, field_name)
return super().filter_queryset(queryset)
def validate_datetime_filter_range(self, cleaned, field_name):
exact_value = cleaned.get(field_name) or cleaned.get(f"{field_name}__date")
gte_value = cleaned.get(f"{field_name}__gte") or exact_value
lte_value = cleaned.get(f"{field_name}__lte") or exact_value
if not (exact_value or gte_value or lte_value):
return
default_value = datetime.now(UTC).date()
gte_value = gte_value or default_value
lte_value = lte_value or default_value
gte_datetime = self.filter_value_to_datetime(gte_value, field_name)
lte_datetime = self.filter_value_to_datetime(lte_value, field_name)
if abs(lte_datetime - gte_datetime) <= timedelta(
days=settings.FINDINGS_MAX_DAYS_IN_RANGE
):
return
raise ValidationError(
[
{
"detail": f"The date range cannot exceed {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.",
"status": 400,
"source": {"pointer": f"/data/attributes/{field_name}"},
"code": "invalid",
}
]
)
# Convert filter values to UUIDv7 values for use with partitioning
def filter_scan_id(self, queryset, name, value):
try:
@@ -824,27 +829,169 @@ class FindingFilter(CommonFindingFilters):
datetime_value = self.maybe_date_to_datetime(value)
start = uuid7_start(datetime_to_uuid7(datetime_value))
end = uuid7_start(datetime_to_uuid7(datetime_value + timedelta(days=1)))
return queryset.filter(id__gte=start, id__lt=end)
def filter_inserted_at_gte(self, queryset, name, value):
datetime_value = self.maybe_date_to_datetime(value)
start = uuid7_start(datetime_to_uuid7(datetime_value))
return queryset.filter(id__gte=start)
def filter_inserted_at_lte(self, queryset, name, value):
datetime_value = self.maybe_date_to_datetime(value)
end = uuid7_start(datetime_to_uuid7(datetime_value + timedelta(days=1)))
return queryset.filter(id__lt=end)
@staticmethod
def maybe_date_to_datetime(value):
dt = value
if isinstance(value, datetime):
return value
if isinstance(value, date):
dt = datetime.combine(value, datetime.min.time(), tzinfo=UTC)
return dt
return datetime.combine(value, datetime.min.time(), tzinfo=UTC)
if isinstance(value, str):
return parse(value)
return value
@classmethod
def filter_value_to_datetime(cls, value, field_name):
try:
datetime_value = cls.maybe_date_to_datetime(value)
except (TypeError, ValueError, OverflowError):
raise ValidationError(
[
{
"detail": "Enter a valid date or datetime.",
"status": 400,
"source": {"pointer": f"/data/attributes/{field_name}"},
"code": "invalid",
}
]
)
if datetime_value.tzinfo is None:
return datetime_value.replace(tzinfo=UTC)
return datetime_value.astimezone(UTC)
class FindingFilter(BaseFindingFilter):
DATE_FILTER_FIELDS = ("inserted_at", "updated_at")
DATE_FILTER_NAMES = (
"inserted_at",
"inserted_at__date",
"inserted_at__gte",
"inserted_at__lte",
"updated_at",
"updated_at__date",
"updated_at__gte",
"updated_at__lte",
)
DATE_FILTER_REQUIRED_DETAIL = (
"At least one date filter is required: filter[inserted_at], filter[updated_at], "
"filter[inserted_at.gte], filter[updated_at.gte], filter[inserted_at.lte], "
"or filter[updated_at.lte]."
)
inserted_at = CharFilter(method="filter_inserted_at")
inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date")
inserted_at__gte = CharFilter(
method="filter_inserted_at",
help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
)
inserted_at__lte = CharFilter(
method="filter_inserted_at",
help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
)
updated_at = CharFilter(method="filter_updated_at")
updated_at__date = DateFilter(method="filter_updated_at", lookup_expr="date")
updated_at__gte = CharFilter(
method="filter_updated_at",
help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
)
updated_at__lte = CharFilter(
method="filter_updated_at",
help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
)
class Meta(BaseFindingFilter.Meta):
fields = FINDING_BASE_FILTER_FIELDS | {
"inserted_at": ["date", "gte", "lte"],
"updated_at": ["date", "gte", "lte"],
}
def filter_inserted_at(self, queryset, name, value):
start, end = self.filter_value_to_datetime_bounds(value, "inserted_at")
if name.endswith("__gte"):
return queryset.filter(id__gte=self.datetime_to_uuid7_boundary(start))
if name.endswith("__lte"):
return queryset.filter(id__lt=self.datetime_to_uuid7_boundary(end))
return queryset.filter(
id__gte=self.datetime_to_uuid7_boundary(start),
id__lt=self.datetime_to_uuid7_boundary(end),
)
def filter_updated_at(self, queryset, name, value):
start, end = self.filter_value_to_datetime_bounds(value, "updated_at")
if name.endswith("__gte"):
return queryset.filter(updated_at__gte=start)
if name.endswith("__lte"):
return queryset.filter(updated_at__lt=end)
return queryset.filter(updated_at__gte=start, updated_at__lt=end)
@classmethod
def filter_value_to_datetime_bounds(cls, value, field_name):
start = cls.filter_value_to_datetime(value, field_name)
if cls.is_date_filter_value(value):
return start, start + timedelta(days=1)
return start, start + timedelta(milliseconds=1)
@staticmethod
def datetime_to_uuid7_boundary(datetime_value):
timestamp_ms = int(datetime_value.timestamp() * 1000) & 0xFFFFFFFFFFFF
uuid_int = timestamp_ms << 80
uuid_int |= 0x7 << 76
uuid_int |= 0x2 << 62
return UUID(int=uuid_int)
@staticmethod
def is_date_filter_value(value):
if isinstance(value, datetime):
return False
if isinstance(value, date):
return True
return isinstance(value, str) and len(value.strip()) == 10
class FindingMetadataFilter(BaseFindingFilter):
DATE_FILTER_FIELDS = ("inserted_at",)
DATE_FILTER_NAMES = (
"inserted_at",
"inserted_at__date",
"inserted_at__gte",
"inserted_at__lte",
)
DATE_FILTER_REQUIRED_DETAIL = (
"At least one date filter is required: filter[inserted_at], filter[inserted_at.gte], "
"or filter[inserted_at.lte]."
)
inserted_at = DateFilter(method="filter_inserted_at", lookup_expr="date")
inserted_at__date = DateFilter(method="filter_inserted_at", lookup_expr="date")
inserted_at__gte = DateFilter(
method="filter_inserted_at_gte",
help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
)
inserted_at__lte = DateFilter(
method="filter_inserted_at_lte",
help_text=BaseFindingFilter.DATE_RANGE_HELP_TEXT,
)
class Meta(BaseFindingFilter.Meta):
fields = FINDING_BASE_FILTER_FIELDS | {
"inserted_at": ["date", "gte", "lte"],
}
class LatestFindingFilter(CommonFindingFilters):
@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0096_attack_paths_scan_is_migrated"),
]
operations = [
migrations.AlterField(
model_name="attackpathsscan",
name="is_migrated",
field=models.BooleanField(db_default=False, default=False),
),
migrations.AlterField(
model_name="attackpathsscan",
name="sink_backend",
field=models.CharField(
choices=[("neo4j", "Neo4j"), ("neptune", "Neptune")],
db_default="neo4j",
default="neo4j",
max_length=16,
),
),
]
+2 -1
View File
@@ -814,9 +814,10 @@ class AttackPathsScan(RowLevelSecurityProtectedModel):
# still using the previous graph shape. Query catalog selection uses this
# flag; physical read routing uses sink_backend below.
# TODO: drop after Neptune cutover
is_migrated = models.BooleanField(default=False)
is_migrated = models.BooleanField(default=False, db_default=False)
sink_backend = models.CharField(
choices=SinkBackendChoices.choices,
db_default=SinkBackendChoices.NEO4J,
default=SinkBackendChoices.NEO4J,
max_length=16,
)
File diff suppressed because it is too large Load Diff
+72 -10
View File
@@ -103,20 +103,84 @@ class TestUserViewSet:
assert response.json()["data"]["attributes"]["name"] == "Updated Name"
def test_partial_update_user_with_no_permissions(
self, authenticated_client_no_permissions_rbac, create_test_user
self, authenticated_client_no_permissions_rbac, create_test_user_rbac_limited
):
updated_data = {
"data": {
"type": "users",
"id": str(create_test_user_rbac_limited.id),
"attributes": {"name": "Updated Name"},
}
}
response = authenticated_client_no_permissions_rbac.patch(
reverse("user-detail", kwargs={"pk": create_test_user.id}),
reverse("user-detail", kwargs={"pk": create_test_user_rbac_limited.id}),
data=updated_data,
format="vnd.api+json",
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_200_OK
assert response.json()["data"]["attributes"]["name"] == "Updated Name"
def test_partial_update_other_user_with_no_permissions_denied(
self, authenticated_client_no_permissions_rbac, tenants_fixture
):
original_email = "target-rbac-update@example.com"
original_password = "OriginalPassword123@"
target_user = User.objects.create_user(
name="target_rbac_update",
email=original_email,
password=original_password,
)
Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
updated_data = {
"data": {
"type": "users",
"id": str(target_user.id),
"attributes": {
"email": "updated-target-rbac@example.com",
"password": "UpdatedPassword123@",
},
}
}
response = authenticated_client_no_permissions_rbac.patch(
reverse("user-detail", kwargs={"pk": target_user.id}),
data=updated_data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
target_user.refresh_from_db()
assert target_user.email == original_email
assert target_user.check_password(original_password)
def test_partial_update_other_user_with_manage_users_allowed(
self, authenticated_client_rbac_manage_users_only
):
user = authenticated_client_rbac_manage_users_only.user
tenant = Membership.objects.filter(user=user).first().tenant
target_user = User.objects.create_user(
name="target_manage_users_update",
email="target-manage-users-update@example.com",
password="Password123@",
)
Membership.objects.create(user=target_user, tenant=tenant)
updated_data = {
"data": {
"type": "users",
"id": str(target_user.id),
"attributes": {"name": "Updated Target Name"},
}
}
response = authenticated_client_rbac_manage_users_only.patch(
reverse("user-detail", kwargs={"pk": target_user.id}),
data=updated_data,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
target_user.refresh_from_db()
assert target_user.name == "Updated Target Name"
def test_delete_user_with_all_permissions(
self, authenticated_client_rbac, create_test_user_rbac
@@ -540,9 +604,7 @@ class TestLimitedVisibility:
TEST_PASSWORD = "Thisisapassword123@"
@pytest.fixture
def limited_admin_user(
self, django_db_setup, django_db_blocker, tenants_fixture, providers_fixture
):
def limited_admin_user(self, django_db_blocker, tenants_fixture, providers_fixture):
with django_db_blocker.unblock():
tenant = tenants_fixture[0]
provider = providers_fixture[0]
@@ -626,10 +688,10 @@ class TestLimitedVisibility:
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
)
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overviews_providers(
self,
authenticated_client_rbac_limited,
scan_summaries_fixture,
providers_fixture,
):
# By default, the associated provider is the one which has the overview data
@@ -648,6 +710,7 @@ class TestLimitedVisibility:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()["data"]) == 0
@pytest.mark.usefixtures("scan_summaries_fixture")
@pytest.mark.parametrize(
"endpoint_name",
[
@@ -659,7 +722,6 @@ class TestLimitedVisibility:
self,
endpoint_name,
authenticated_client_rbac_limited,
scan_summaries_fixture,
providers_fixture,
):
# By default, the associated provider is the one which has the overview data
@@ -684,10 +746,10 @@ class TestLimitedVisibility:
data = response.json()["data"]["attributes"].values()
assert all(value == 0 for value in data)
@pytest.mark.usefixtures("scan_summaries_fixture")
def test_overviews_services(
self,
authenticated_client_rbac_limited,
scan_summaries_fixture,
providers_fixture,
):
# By default, the associated provider is the one which has the overview data
+259
View File
@@ -57,6 +57,7 @@ from api.models import (
UserRoleRelationship,
)
from api.rls import Tenant
from api.uuid_utils import datetime_to_uuid7
from api.v1.serializers import TokenSerializer
from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
from botocore.exceptions import ClientError, NoCredentialsError
@@ -243,6 +244,63 @@ class TestUserViewSet:
create_test_user.refresh_from_db()
assert create_test_user.company_name == new_company_name
def test_users_partial_update_same_tenant_other_user_password_denied(
self, authenticated_client_no_permissions_rbac, tenants_fixture
):
original_password = "OriginalPassword123@"
new_password = "UpdatedPassword123@"
target_user = User.objects.create_user(
password=original_password,
email="target-password-update@example.com",
)
Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
payload = {
"data": {
"type": "users",
"id": str(target_user.id),
"attributes": {"password": new_password},
},
}
response = authenticated_client_no_permissions_rbac.patch(
reverse("user-detail", kwargs={"pk": target_user.id}),
data=payload,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
target_user.refresh_from_db()
assert target_user.check_password(original_password)
assert not target_user.check_password(new_password)
def test_users_partial_update_same_tenant_other_user_email_denied(
self, authenticated_client_no_permissions_rbac, tenants_fixture
):
original_email = "target-email-update@example.com"
new_email = "updated-target-email@example.com"
target_user = User.objects.create_user(
password="OriginalPassword123@",
email=original_email,
)
Membership.objects.create(user=target_user, tenant=tenants_fixture[0])
payload = {
"data": {
"type": "users",
"id": str(target_user.id),
"attributes": {"email": new_email},
},
}
response = authenticated_client_no_permissions_rbac.patch(
reverse("user-detail", kwargs={"pk": target_user.id}),
data=payload,
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
target_user.refresh_from_db()
assert target_user.email == original_email
def test_users_partial_update_invalid_content_type(
self, authenticated_client, create_test_user
):
@@ -7238,6 +7296,26 @@ class TestFindingViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["errors"][0]["code"] == "invalid"
def test_findings_updated_at_range_too_large_with_inserted_at_filter(
self, authenticated_client
):
response = authenticated_client.get(
reverse("finding-list"),
{
"filter[inserted_at]": TODAY,
"filter[updated_at.gte]": today_after_n_days(
-(settings.FINDINGS_MAX_DAYS_IN_RANGE + 1)
),
"filter[updated_at.lte]": TODAY,
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["errors"][0]["code"] == "invalid"
assert response.json()["errors"][0]["source"]["pointer"] == (
"/data/attributes/updated_at"
)
def test_findings_list(self, authenticated_client, findings_fixture):
response = authenticated_client.get(
reverse("finding-list"), {"filter[inserted_at]": TODAY}
@@ -7249,6 +7327,170 @@ class TestFindingViewSet:
== findings_fixture[0].status
)
def test_findings_list_inserted_at_accepts_timestamp_precision_filters(
self, authenticated_client, scans_fixture
):
scan, *_ = scans_fixture
def create_finding(uid, inserted_at):
finding = Finding.objects.create(
id=datetime_to_uuid7(inserted_at),
tenant_id=scan.tenant_id,
uid=uid,
scan=scan,
status=Status.FAIL,
status_extended="timestamp precision status",
impact=Severity.medium,
severity=Severity.medium,
check_id="timestamp_precision_check",
check_metadata={
"CheckId": "timestamp_precision_check",
"Description": "timestamp precision check",
"servicename": "ec2",
},
first_seen_at=inserted_at,
)
Finding.all_objects.filter(pk=finding.pk).update(
inserted_at=inserted_at,
updated_at=inserted_at,
)
finding.refresh_from_db()
return finding
create_finding(
"timestamp_precision_early",
datetime(2026, 1, 15, 10, 30, 0, 100000, tzinfo=UTC),
)
late_finding = create_finding(
"timestamp_precision_late",
datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC),
)
response = authenticated_client.get(
reverse("finding-list"),
{
"filter[inserted_at.gte]": "2026-01-15T10:30:00.150Z",
"filter[inserted_at.lte]": "2026-01-15T10:30:00.250Z",
},
)
assert response.status_code == status.HTTP_200_OK
returned_uids = {
finding["attributes"]["uid"] for finding in response.json()["data"]
}
assert returned_uids == {late_finding.uid}
response = authenticated_client.get(
reverse("finding-list"),
{"filter[inserted_at]": "2026-01-15T10:30:00.200Z"},
)
assert response.status_code == status.HTTP_200_OK
returned_uids = {
finding["attributes"]["uid"] for finding in response.json()["data"]
}
assert returned_uids == {late_finding.uid}
def test_findings_list_updated_at_accepts_timestamp_precision_filters(
self, authenticated_client, findings_fixture
):
early_finding, late_finding, *_ = findings_fixture
early_updated_at = datetime(2026, 1, 15, 10, 30, 0, 100000, tzinfo=UTC)
late_updated_at = datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC)
Finding.all_objects.filter(pk=early_finding.pk).update(
updated_at=early_updated_at
)
Finding.all_objects.filter(pk=late_finding.pk).update(
updated_at=late_updated_at
)
response = authenticated_client.get(
reverse("finding-list"),
{
"filter[updated_at.gte]": "2026-01-15T10:30:00.150Z",
"filter[updated_at.lte]": "2026-01-15T10:30:00.250Z",
},
)
assert response.status_code == status.HTTP_200_OK
returned_uids = {
finding["attributes"]["uid"] for finding in response.json()["data"]
}
assert returned_uids == {late_finding.uid}
response = authenticated_client.get(
reverse("finding-list"),
{"filter[updated_at]": "2026-01-15T10:30:00.200Z"},
)
assert response.status_code == status.HTTP_200_OK
returned_uids = {
finding["attributes"]["uid"] for finding in response.json()["data"]
}
assert returned_uids == {late_finding.uid}
def test_findings_list_inserted_at_and_updated_at_filters_are_combined(
self, authenticated_client, scans_fixture
):
scan, *_ = scans_fixture
def create_finding(uid, inserted_at, updated_at):
finding = Finding.objects.create(
id=datetime_to_uuid7(inserted_at),
tenant_id=scan.tenant_id,
uid=uid,
scan=scan,
status=Status.FAIL,
status_extended="timestamp precision status",
impact=Severity.medium,
severity=Severity.medium,
check_id="timestamp_precision_check",
check_metadata={
"CheckId": "timestamp_precision_check",
"Description": "timestamp precision check",
"servicename": "ec2",
},
first_seen_at=inserted_at,
)
Finding.all_objects.filter(pk=finding.pk).update(
inserted_at=inserted_at,
updated_at=updated_at,
)
finding.refresh_from_db()
return finding
matching_finding = create_finding(
"timestamp_precision_combined_match",
datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC),
datetime(2026, 1, 15, 11, 30, 0, 200000, tzinfo=UTC),
)
create_finding(
"timestamp_precision_combined_inserted_only",
datetime(2026, 1, 15, 10, 30, 0, 200000, tzinfo=UTC),
datetime(2026, 1, 15, 12, 30, 0, 200000, tzinfo=UTC),
)
create_finding(
"timestamp_precision_combined_updated_only",
datetime(2026, 1, 15, 9, 30, 0, 200000, tzinfo=UTC),
datetime(2026, 1, 15, 11, 30, 0, 200000, tzinfo=UTC),
)
response = authenticated_client.get(
reverse("finding-list"),
{
"filter[inserted_at.gte]": "2026-01-15T10:30:00.150Z",
"filter[inserted_at.lte]": "2026-01-15T10:30:00.250Z",
"filter[updated_at.gte]": "2026-01-15T11:30:00.150Z",
"filter[updated_at.lte]": "2026-01-15T11:30:00.250Z",
},
)
assert response.status_code == status.HTTP_200_OK
returned_uids = {
finding["attributes"]["uid"] for finding in response.json()["data"]
}
assert returned_uids == {matching_finding.uid}
def test_findings_list_resource_tags_no_n_plus_one(
self, authenticated_client, findings_fixture
):
@@ -7714,6 +7956,23 @@ class TestFindingViewSet:
]
}
@pytest.mark.parametrize(
"filter_name",
["inserted_at", "inserted_at.gte", "inserted_at.lte"],
)
def test_findings_metadata_rejects_timestamp_precision_filters(
self, authenticated_client, filter_name
):
response = authenticated_client.get(
reverse("finding-metadata"),
{f"filter[{filter_name}]": "2048-01-01T10:30:00Z"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
error = response.json()["errors"][0]
assert error["detail"] == "Enter a valid date."
assert error["code"] == "invalid"
def test_findings_metadata_backfill(
self, authenticated_client, scans_fixture, findings_fixture
):
+23 -2
View File
@@ -50,6 +50,7 @@ from api.filters import (
FindingGroupAggregatedComputedFilter,
FindingGroupFilter,
FindingGroupSummaryFilter,
FindingMetadataFilter,
IntegrationFilter,
IntegrationJiraFindingsFilter,
InvitationFilter,
@@ -947,8 +948,8 @@ class UserViewSet(BaseUserViewset):
"""
Returns the required permissions based on the request method.
"""
if self.action == "me":
# No permissions required for me request
if self.action in ["me", "partial_update"]:
# No permissions required for me and partial_update requests
self.required_permissions = []
else:
# Require permission for the rest of the requests
@@ -1002,6 +1003,24 @@ class UserViewSet(BaseUserViewset):
status=status.HTTP_200_OK,
)
def partial_update(self, request, *args, **kwargs):
user = self.get_object()
if user.id != self.request.user.id:
role = get_role(self.request.user, self.request.tenant_id)
if not getattr(role, Permissions.MANAGE_USERS.value, False):
raise ValidationError(
"Only users with manage users permission can update other users."
)
serializer = self.get_serializer(user, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
if getattr(user, "_prefetched_objects_cache", None):
user._prefetched_objects_cache = {}
return Response(serializer.data)
def destroy(self, request, *args, **kwargs):
if kwargs["pk"] != str(self.request.user.id):
raise ValidationError("Only the current user can be deleted.")
@@ -3833,6 +3852,8 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
def get_filterset_class(self):
if self.action in ["latest", "metadata_latest"]:
return LatestFindingFilter
if self.action == "metadata":
return FindingMetadataFilter
return FindingFilter
def get_queryset(self):
@@ -2,8 +2,10 @@ from contextlib import nullcontext
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
from uuid import uuid4
import pytest
from api.db_utils import rls_transaction
from api.models import (
AttackPathsScan,
Finding,
@@ -15,6 +17,7 @@ from api.models import (
StatusChoices,
Task,
)
from django.db import DEFAULT_DB_ALIAS
from django_celery_results.models import TaskResult
from prowler.lib.check.models import Severity
from tasks.jobs.attack_paths import findings as findings_module
@@ -2244,6 +2247,58 @@ class TestInternetAnalysis:
class TestAttackPathsDbUtilsGraphDataReady:
"""Tests for db_utils functions related to graph_data_ready lifecycle."""
def test_database_defaults_allow_legacy_insert_without_cutover_columns(
self, tenants_fixture, providers_fixture, scans_fixture
):
tenant = tenants_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
scan = scans_fixture[0]
scan.provider = provider
scan.save()
attack_paths_scan_id = uuid4()
now = datetime.now(tz=UTC)
with rls_transaction(str(tenant.id), using=DEFAULT_DB_ALIAS) as cursor:
cursor.execute(
"""
INSERT INTO attack_paths_scans (
id,
inserted_at,
updated_at,
state,
progress,
graph_data_ready,
started_at,
tenant_id,
provider_id,
scan_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
[
attack_paths_scan_id,
now,
now,
StateChoices.SCHEDULED,
0,
False,
now,
tenant.id,
provider.id,
scan.id,
],
)
attack_paths_scan = AttackPathsScan.objects.get(id=attack_paths_scan_id)
assert attack_paths_scan.is_migrated is False
assert (
attack_paths_scan.sink_backend == AttackPathsScan.SinkBackendChoices.NEO4J
)
def test_create_attack_paths_scan_first_scan_defaults_to_false(
self, tenants_fixture, providers_fixture, scans_fixture
):
Generated
+1 -1
View File
@@ -4762,7 +4762,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.33.0"
version = "1.34.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
+2
View File
@@ -128,6 +128,7 @@
"user-guide/tutorials/prowler-scan-scheduling",
"user-guide/tutorials/prowler-alerts",
"user-guide/tutorials/prowler-app-scan-configuration",
"user-guide/tutorials/prowler-app-findings-triage",
{
"group": "Mutelist",
"expanded": true,
@@ -237,6 +238,7 @@
"user-guide/providers/azure/authentication",
"user-guide/providers/azure/use-non-default-cloud",
"user-guide/providers/azure/subscriptions",
"user-guide/providers/azure/resource-groups",
"user-guide/providers/azure/create-prowler-service-principal"
]
},
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.31.0"
PROWLER_API_VERSION="5.31.0"
PROWLER_UI_VERSION="5.32.0"
PROWLER_API_VERSION="5.32.0"
```
<Note>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 575 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

@@ -0,0 +1,47 @@
---
title: 'Azure Resource Group Scope'
---
Prowler supports narrowing security scans to specific resource groups within Azure subscriptions. This is useful when you want to audit only a subset of resources rather than scanning an entire subscription.
By default, Prowler scans all resource groups it has permission to access. Passing `--azure-resource-group` limits the scan to only the specified resource groups across all accessible subscriptions.
## Configuring Resource Group Scoped Scans
To restrict a scan to one or more resource groups, pass them as arguments using the `--azure-resource-group` flag:
```console
prowler azure --az-cli-auth --azure-resource-group <resource-group-1> <resource-group-2> ... <resource-group-N>
```
For example, to scan only `rg-production` and `rg-staging`:
```console
prowler azure --az-cli-auth --azure-resource-group rg-prod1 rg-prod2
```
This works with all supported authentication methods:
```console
# Service Principal
prowler azure --sp-env-auth --azure-resource-group rg-production
# Browser
prowler azure --browser-auth --tenant-id <tenant-id> --azure-resource-group rg-production
# Managed Identity
prowler azure --managed-identity-auth --azure-resource-group rg-production
```
## How It Works
When `--azure-resource-group` is provided, Prowler validates each specified resource group against all accessible subscriptions. A resource group is included in the scan if it exists in **at least one** subscription.
- If a resource group is found in one or more subscriptions, it will be scanned in those subscriptions only.
- If a resource group is **not found in any** subscription, Prowler logs a warning and skips it.
- If **none** of the provided resource groups are found across any subscription, Prowler logs a warning and no resource group scoped checks will run.
- Resource group names are matched case-insensitively, so `MyGroup` and `mygroup` are treated as the same group, mirroring Azure's own behavior.
<Warning>
If `--azure-resource-group` is used, checks that apply to specific resources are limited to the relevant resource groups. But if checks that apply to tenant or subscription scope (identity, policy, or subscription-level configuration checks) are involved, then these checks will run in their natural scope.
</Warning>
@@ -21,13 +21,13 @@ By mapping these relationships as a graph, Attack Paths reveals risks that indiv
The following prerequisites are required for Attack Paths:
- **An AWS provider is configured** with valid credentials in Prowler App. For setup instructions, see [Getting Started with AWS](/user-guide/providers/aws/getting-started-aws).
- **At least one scan has completed** on the configured AWS provider. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required.
- **At least one scan has completed** on the configured AWS provider and produced graph data. Attack Paths scans run automatically alongside regular security scans, no separate configuration is required.
## How Attack Paths Scans Work
Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources.
Once the scan finishes and the graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status. Scans that are still processing display as **Executing** or **Scheduled**.
Once the scan finishes and graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status and a check in the **Graph** column. Scans that are still queued or running remain visible, but they cannot be selected until graph data is ready.
<Note>
Since Prowler scans all configured providers every **24 hours** by default,
@@ -41,25 +41,28 @@ To open Attack Paths, click **Attack Paths** in the left navigation menu.
<img
src="/images/prowler-app/attack-paths/navigation.png"
alt="Attack Paths navigation menu entry"
width="700"
width="320"
/>
The main interface is divided into two areas:
The Attack Paths page guides you through the workflow on one page:
- **Left panel:** A table listing all available Attack Paths scans
- **Right panel:** The query selector, parameter form, and execute controls
- Select a scan with graph data.
- Choose a built-in query or a custom openCypher query.
- Add parameters when the selected query requires them.
- Execute the query and explore the resulting graph.
## Selecting a Scan
The scans table displays all Attack Paths scans with the following columns:
- **Provider / Account:** The AWS provider alias and account identifier
- **Last scan date:** When the scan completed
- **Status:** Current state of the scan (Completed, Executing, Scheduled, or Failed)
- **Progress:** Completion percentage for in-progress scans
- **Duration:** Total scan time
- **Select:** A radio button used to choose a scan. The radio button is disabled when graph data is not available.
- **Provider:** The AWS provider alias and account identifier.
- **Last Scan Date:** When the scan completed.
- **Status:** Current state of the scan, such as **Completed**, **Executing**, **Scheduled**, or **Failed**.
- **Graph:** Whether Attack Paths graph data is available for the scan.
- **Duration:** Total scan time.
To select a scan for analysis, click **Select** on any row with a **Completed** status.
To select a scan for analysis, click the radio button on any row with a **Completed** status and available graph data.
<img
src="/images/prowler-app/attack-paths/scan-list-table.png"
@@ -68,19 +71,18 @@ To select a scan for analysis, click **Select** on any row with a **Completed**
/>
<Note>
Only scans with a **Completed** status and ready graph data can be selected.
Scans that are still executing or have failed appear with disabled action
buttons.
Only scans with graph data can be selected. Disabled rows include a tooltip
that explains why the graph is not available yet.
</Note>
## Choosing a Query
After selecting a scan, the right panel activates a query dropdown. Each query targets a specific type of privilege escalation or misconfiguration pattern.
After selecting a scan, the query selector becomes available. Each query targets a specific privilege escalation, exposure, inventory, or misconfiguration pattern.
To choose a query, click the dropdown and select from the available options. Each option displays:
- **Query name:** A descriptive title (e.g., "IAM Privilege Escalation via AssumeRole")
- **Short description:** A brief summary of what the query detects
- **Query name:** A descriptive title, such as **Internet-Exposed EC2 with Sensitive S3 Access**.
- **Short description:** A brief summary of what the query detects.
<img
src="/images/prowler-app/attack-paths/query-selector.png"
@@ -88,16 +90,17 @@ To choose a query, click the dropdown and select from the available options. Eac
width="700"
/>
Once selected, a description card appears below the dropdown with additional context about the query, including attribution links to external references when available.
Once selected, a description panel appears below the dropdown with more context about the query.
## Configuring Query Parameters
Some queries accept optional or required parameters to narrow the scope of the analysis. When a query has parameters, a dynamic form appears below the query description.
Some queries accept optional or required parameters to narrow the scope of the analysis. When a query has parameters, a form appears below the query description.
- **Required fields** are marked with an asterisk (\*) and must be filled before executing
- **Optional fields** refine the query results but are not mandatory
- **Required fields** are marked with an asterisk (\*) and must be filled before executing.
- **Optional fields** refine the query results but are not mandatory.
- Queries without parameters show no parameter form.
If a query requires no parameters, the form displays a message confirming that the query is ready to execute.
For example, **Internet-Exposed EC2 with Sensitive S3 Access** uses **Tag key** and **Tag value** fields to identify sensitive S3 buckets.
<img
src="/images/prowler-app/attack-paths/query-parameters.png"
@@ -117,7 +120,7 @@ Custom queries are sandboxed to keep the graph database safe and responsive:
- **Read-only:** Only read operations are allowed. Statements that mutate the graph (`CREATE`, `MERGE`, `SET`, `DELETE`, `REMOVE`, `DROP`, `LOAD CSV`, `CALL { ... }` writes, etc.) are rejected before execution.
- **Length limit:** Each query is capped at **10,000 characters**.
- **Scoped to the selected scan:** Results are automatically scoped to the provider and scan selected on the left panel. There is no need to filter by tenant or scan identifier in the query body.
- **Scoped to the selected scan:** Results are automatically scoped to the provider and scan selected in the scan table. There is no need to filter by tenant or scan identifier in the query body.
### Example Queries
@@ -284,53 +287,75 @@ In addition to the upstream schema, Prowler enriches the graph with:
## Executing a Query
To run the selected query against the scan data, click **Execute Query**. The button displays a loading state while the query processes.
To run the selected query against the scan data, click **Execute Query**. The button is disabled until a query is selected and all required parameters are valid.
The button displays a loading state while the query runs. After the query completes, the graph appears below the query builder.
If the query returns no results, an informational message appears. Common reasons include:
- **No matching patterns found:** The scanned environment does not contain the privilege escalation chain the query targets
- **Insufficient permissions:** The scan credentials may not have captured all the data the query needs
- **No matching patterns found:** The scanned environment does not contain the pattern the query targets.
- **Not enough permissions:** The scan credentials may not have captured all the data the query needs.
- **Server unavailable:** The graph service may be temporarily unavailable.
<img
src="/images/prowler-app/attack-paths/execute-query.png"
alt="Attack Paths right panel with query selected and execute button"
alt="Attack Paths query builder with query selected and execute button"
width="700"
/>
## Exploring the Graph
After a successful execution, the graph visualization renders below the query builder in a full-width panel. The graph maps relationships between cloud resources, IAM entities, and security findings.
After a successful execution, the graph visualization renders below the query builder. The graph maps relationships between cloud resources, IAM entities, public exposure, and security findings.
### Node Types
- **Resource nodes** (rounded pills): Represent cloud resources such as IAM roles, policies, EC2 instances, and S3 buckets. Each resource type has a distinct color.
- **Finding nodes** (hexagons): Represent Prowler security findings linked to resources in the graph. Colors indicate severity level (critical, high, medium, low).
- **Provider root nodes:** Represent the AWS account or provider root for the selected scan.
- **Resource nodes:** Represent cloud resources such as IAM roles, policies, EC2 instances, security groups, and S3 buckets.
- **Internet nodes:** Represent exposure from the public internet.
- **Finding nodes:** Represent Prowler findings linked to resources. Finding colors indicate risk level, such as critical, high, medium, or low.
### Edge Types
- **Solid lines:** Direct relationships between resources (e.g., a role attached to a policy)
- **Dashed lines:** Connections between resources and their associated findings
- **Normal edges:** Direct relationships between graph nodes, such as role-to-policy or resource-to-security-group relationships.
- **Finding edges:** Dashed relationships between resources and their associated findings.
- **Highlighted paths:** Green edges that show the active path when you hover a node or focus a finding.
A **legend** at the bottom of the graph lists all node types and edge types present in the current view.
The standard graph view includes a minimap and a legend below the canvas. The legend shows the provider roots, visible node types, finding risk levels, node states, and edge types present in the current view.
<img
src="/images/prowler-app/attack-paths/graph-visualization.png"
alt="Attack Paths graph showing nodes, edges, and legend"
alt="Attack Paths graph showing nodes and edges"
width="700"
/>
## Interacting with the Graph
### Filtering by Node
The graph banner describes the main interactions:
Click any node in the graph to filter the view and display only paths that pass through that node. When a filter is active:
- Click a finding to focus its connected path.
- Click a resource with findings to show or hide its related findings.
- Hover a node to highlight its connected path.
- An information banner shows which node is selected
- Click **Back to Full View** to restore the complete graph
### Showing Related Findings
Resource nodes with related findings are clickable. Click one of these resources to show its finding nodes. Click the resource again to hide them.
The graph automatically fits the selected resource and its related findings when the findings are shown.
### Focusing a Finding Path
Click a finding node to focus the graph on the path connected to that finding. When the graph is focused:
- The graph shows **Back to Full View**.
- The status banner shows the selected finding.
- The graph keeps only the connected path in view.
- The finding detail drawer opens.
After you close the drawer, the graph remains focused on the selected path.
<img
src="/images/prowler-app/attack-paths/graph-filtered.png"
alt="Attack Paths graph filtered to show paths through a selected node"
alt="Attack Paths graph focused on a selected finding path"
width="700"
/>
@@ -339,29 +364,29 @@ Click any node in the graph to filter the view and display only paths that pass
The toolbar in the top-right corner of the graph provides:
- **Zoom in / Zoom out:** Adjust the zoom level
- **Fit to screen:** Reset the view to fit all nodes
- **Export:** Download the current graph as an SVG file
- **Fullscreen:** Open the graph in a full-screen modal with a side-by-side node detail panel
- **Fit graph to view:** Reset the view to fit the visible graph
- **Export graph:** Download the current graph as a PNG file
- **Fullscreen:** Open the graph in a full-size modal
<Note>
Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within
the graph area.
</Note>
## Viewing Node Details
## Viewing Finding Details
Click any node to open the **Node Details** panel below the graph. This panel displays:
Click a finding node to open the finding detail drawer. The drawer uses the same finding detail layout as the Findings page and includes:
- **Node type:** The resource category (e.g., "IAM Role," "EC2 Instance")
- **Properties:** All attributes of the selected node, including identifiers, timestamps, and configuration details
- **Related findings** (for resource nodes): A list of Prowler findings linked to the resource, with severity, title, and status
- **Affected resources** (for finding nodes): A list of resources associated with the finding
- The finding title, status, and severity.
- The affected resource summary.
- Overview, remediation, evidence, related findings, scans, and events tabs when data is available.
- A Lighthouse AI action when the account has access to Lighthouse AI.
For finding nodes, a "View Finding" button links directly to the finding detail page for further investigation.
Resource nodes do not open a node detail panel. When a resource has related findings, clicking it expands or collapses those finding nodes in the graph.
<img
src="/images/prowler-app/attack-paths/node-details.png"
alt="Attack Paths node detail panel showing properties and related findings"
alt="Attack Paths finding detail drawer"
width="700"
/>
@@ -369,16 +394,28 @@ For finding nodes, a "View Finding" button links directly to the finding detail
To expand the graph for detailed exploration, click the fullscreen icon in the graph toolbar. The fullscreen modal provides:
- The full graph visualization with all zoom and export controls
- A side panel for node details that appears when a node is selected
- All filtering and interaction capabilities available in the standard view
- The graph in a full-size modal.
- The same zoom, fit, and export controls.
- The same node expansion, finding focus, hover highlight, and minimap interactions available in the standard view.
<img
src="/images/prowler-app/attack-paths/fullscreen-mode.png"
alt="Attack Paths fullscreen mode with graph and node detail side panel"
alt="Attack Paths fullscreen graph mode"
width="700"
/>
## Available Queries
The query selector includes custom openCypher and built-in AWS queries for common security investigation workflows. Available queries are loaded from the selected scan and may change as new query packs are added.
Available queries include:
- **Custom openCypher query:** Write and run a read-only graph query.
- **Exposure queries:** Find internet-exposed EC2 instances, load balancers, open security groups, and resources by public IP.
- **Inventory queries:** List resources such as RDS instances.
- **Misconfiguration queries:** Find unencrypted RDS instances, public S3 buckets, and wildcard IAM statements.
- **Privilege escalation queries:** Detect IAM and AWS service paths based on known attack techniques, including queries based on [pathfinding.cloud](https://pathfinding.cloud) research by Datadog.
## Using Attack Paths with the MCP Server and Lighthouse AI
Attack Paths capabilities are also available through the [Prowler MCP Server](/getting-started/products/prowler-mcp), enabling interaction with Attack Paths data via AI assistants like Claude Desktop, Cursor, and other MCP clients.
@@ -387,10 +424,10 @@ Attack Paths capabilities are also available through the [Prowler MCP Server](/g
The following MCP tools are available for Attack Paths:
- **`prowler_app_list_attack_paths_scans`** - List and filter Attack Paths scans
- **`prowler_app_list_attack_paths_queries`** - Discover available queries for a completed scan
- **`prowler_app_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries
- **`prowler_app_list_attack_paths_scans`** - List and filter Attack Paths scans.
- **`prowler_app_list_attack_paths_queries`** - Discover available queries for a completed scan.
- **`prowler_app_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships.
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries.
### Example Questions
@@ -405,110 +442,6 @@ Ask through the MCP Server or Lighthouse AI:
- "Are there any CloudFormation stacks that could be hijacked for privilege escalation?"
- "Show me all roles that can be assumed for lateral movement"
### Supported Queries
Attack Paths currently supports the following built-in queries for AWS:
#### Custom Attack Path Queries
| Query | Description |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Internet-Exposed EC2 with Sensitive S3 Access** | Find SSH-exposed EC2 instances that can assume roles to read tagged sensitive S3 buckets |
#### Basic Resource Queries
| Query | Description |
| ------------------------------------------- | ------------------------------------------------------------------- |
| **RDS Instances Inventory** | List all provisioned RDS database instances in the account |
| **Unencrypted RDS Instances** | Find RDS instances with storage encryption disabled |
| **S3 Buckets with Anonymous Access** | Find S3 buckets that allow anonymous access |
| **IAM Statements Allowing All Actions** | Find IAM policy statements that allow all actions via wildcard (\*) |
| **IAM Statements Allowing Policy Deletion** | Find IAM policy statements that allow iam:DeletePolicy |
| **IAM Statements Allowing Create Actions** | Find IAM policy statements that allow any create action |
#### Network Exposure Queries
| Query | Description |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------- |
| **Internet-Exposed EC2 Instances** | Find EC2 instances flagged as exposed to the internet |
| **Open Security Groups on Internet-Facing Resources** | Find internet-facing resources with security groups allowing inbound from 0.0.0.0/0 |
| **Internet-Exposed Classic Load Balancers** | Find Classic Load Balancers exposed to the internet with their listeners |
| **Internet-Exposed ALB/NLB Load Balancers** | Find ELBv2 (ALB/NLB) load balancers exposed to the internet with their listeners |
| **Resource Lookup by Public IP** | Find the AWS resource associated with a given public IP address |
#### Privilege Escalation Queries
These queries are based on research from [pathfinding.cloud](https://pathfinding.cloud) by Datadog.
| Query | Description |
| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **App Runner Service Creation with Privileged Role (APPRUNNER-001)** | Create an App Runner service with a privileged IAM role to gain its permissions |
| **App Runner Service Update for Role Access (APPRUNNER-002)** | Update an existing App Runner service to leverage its already-attached privileged role |
| **Bedrock Code Interpreter with Privileged Role (BEDROCK-001)** | Create a Bedrock AgentCore Code Interpreter with a privileged role attached |
| **Bedrock Code Interpreter Session Hijacking (BEDROCK-002)** | Start a session on an existing Bedrock code interpreter to exfiltrate its privileged role credentials |
| **CloudFormation Stack Creation with Privileged Role (CLOUDFORMATION-001)** | Create a CloudFormation stack with a privileged role to provision arbitrary AWS resources |
| **CloudFormation Stack Update for Role Access (CLOUDFORMATION-002)** | Update an existing CloudFormation stack to leverage its already-attached privileged service role |
| **CloudFormation StackSet Creation with Privileged Role (CLOUDFORMATION-003)** | Create a CloudFormation StackSet with a privileged execution role to provision arbitrary resources across accounts |
| **CloudFormation StackSet Update with Privileged Role (CLOUDFORMATION-004)** | Update an existing CloudFormation StackSet to inject malicious resources using a privileged execution role |
| **CloudFormation Change Set Privilege Escalation (CLOUDFORMATION-005)** | Create and execute a change set on an existing stack to leverage its privileged service role |
| **CodeBuild Project Creation with Privileged Role (CODEBUILD-001)** | Create a CodeBuild project with a privileged role to execute arbitrary code via a malicious buildspec |
| **CodeBuild Buildspec Override for Role Access (CODEBUILD-002)** | Start a build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
| **CodeBuild Batch Buildspec Override for Role Access (CODEBUILD-003)** | Start a batch build on an existing CodeBuild project with a buildspec override to execute code with its privileged role |
| **CodeBuild Batch Project Creation with Privileged Role (CODEBUILD-004)** | Create a CodeBuild project configured for batch builds with a privileged role to execute arbitrary code via a malicious buildspec |
| **Data Pipeline Creation with Privileged Role (DATAPIPELINE-001)** | Create a Data Pipeline with a privileged role to execute arbitrary commands on provisioned infrastructure |
| **EC2 Instance Launch with Privileged Role (EC2-001)** | Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS |
| **EC2 Role Hijacking via UserData Injection (EC2-002)** | Inject malicious scripts into EC2 instance userData to gain the attached role's permissions |
| **Spot Instance Launch with Privileged Role (EC2-003)** | Launch EC2 Spot Instances with privileged IAM roles to gain their permissions via IMDS |
| **Launch Template Poisoning for Role Access (EC2-004)** | Inject malicious userData into launch templates that reference privileged roles, no PassRole needed |
| **EC2 Instance Connect SSH Access for Role Credentials (EC2INSTANCECONNECT-003)** | Push a temporary SSH key to an EC2 instance via Instance Connect to access its attached role credentials through IMDS |
| **ECS Service Creation with Privileged Role (ECS-001 - New Cluster)** | Create an ECS cluster and service with a privileged Fargate task role to execute arbitrary code |
| **ECS Task Execution with Privileged Role (ECS-002 - New Cluster)** | Create an ECS cluster and run a one-off Fargate task with a privileged role to execute arbitrary code |
| **ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)** | Deploy a Fargate service with a privileged role on an existing ECS cluster |
| **ECS Task Execution with Privileged Role (ECS-004 - Existing Cluster)** | Run a one-off Fargate task with a privileged role on an existing ECS cluster |
| **ECS Task Start with Privileged Role on EC2 (ECS-005 - Existing Cluster)** | Register a task definition with a privileged role and start it on an EC2 container instance to execute arbitrary code |
| **ECS Exec Container Hijacking for Role Credentials (ECS-006)** | Shell into a running ECS container via ECS Exec to steal the attached task role's credentials |
| **Glue Dev Endpoint with Privileged Role (GLUE-001)** | Create a Glue development endpoint with a privileged role attached to gain its permissions |
| **Glue Dev Endpoint SSH Hijacking via Update (GLUE-002)** | Update an existing Glue development endpoint to inject an SSH public key and access its attached role credentials |
| **Glue Job Creation with Privileged Role (GLUE-003)** | Create a Glue job with a privileged role and start it to execute arbitrary code with that role's permissions |
| **Glue Job Creation with Scheduled Trigger and Privileged Role (GLUE-004)** | Create a Glue job with a privileged role and a scheduled trigger to persistently execute arbitrary code |
| **Glue Job Hijacking via Update with Privileged Role (GLUE-005)** | Update an existing Glue job to attach a privileged role and inject malicious code, then start it to gain that role's permissions |
| **Glue Job Hijacking with Scheduled Trigger and Privileged Role (GLUE-006)** | Update an existing Glue job to attach a privileged role and inject malicious code, then create a scheduled trigger for persistent automated execution |
| **Policy Version Override for Self-Escalation (IAM-001)** | Create a new version of an attached policy with administrative permissions, instantly escalating the principal's own privileges |
| **Access Key Creation for Lateral Movement (IAM-002)** | Create access keys for other IAM users to gain their permissions and move laterally across the account |
| **Access Key Rotation Attack for Lateral Movement (IAM-003)** | Delete and recreate access keys for other IAM users to bypass the two-key limit and gain their permissions |
| **Console Login Profile Creation for Lateral Movement (IAM-004)** | Create console login profiles for other IAM users to access the AWS Console with their permissions |
| **Inline Policy Injection for Self-Escalation (IAM-005)** | Attach an inline policy with administrative permissions to your own role, instantly escalating privileges |
| **Console Password Override for Lateral Movement (IAM-006)** | Change the console password of other IAM users to log in as them and gain their permissions |
| **Inline Policy Injection on User for Self-Escalation (IAM-007)** | Attach an inline policy with administrative permissions to your own IAM user, instantly escalating privileges |
| **Managed Policy Attachment on User for Self-Escalation (IAM-008)** | Attach existing managed policies with administrative permissions to your own IAM user, instantly escalating privileges |
| **Managed Policy Attachment on Role for Self-Escalation (IAM-009)** | Attach existing managed policies with administrative permissions to your own IAM role, instantly escalating privileges |
| **Managed Policy Attachment on Group for Self-Escalation (IAM-010)** | Attach existing managed policies with administrative permissions to a group you belong to, escalating privileges for all group members |
| **Inline Policy Injection on Group for Self-Escalation (IAM-011)** | Attach an inline policy with administrative permissions to a group you belong to, escalating privileges for all group members |
| **Trust Policy Hijacking for Role Assumption (IAM-012)** | Modify a role's trust policy to allow yourself to assume it, gaining the role's permissions |
| **Group Membership Hijacking for Privilege Escalation (IAM-013)** | Add yourself to a privileged IAM group to inherit its permissions, gaining access to all policies attached to the group |
| **Managed Policy Attachment with Role Assumption for Lateral Movement (IAM-014)** | Attach administrative managed policies to another role you can assume, then assume it to gain elevated privileges |
| **Managed Policy Attachment with Access Key Creation for Lateral Movement (IAM-015)** | Attach administrative managed policies to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
| **Policy Version Override with Role Assumption for Lateral Movement (IAM-016)** | Create a new version of a customer-managed policy attached to another role with administrative permissions, then assume that role to gain elevated access |
| **Inline Policy Injection with Role Assumption for Lateral Movement (IAM-017)** | Attach an inline policy with administrative permissions to another role you can assume, then assume it to gain elevated privileges |
| **Inline Policy Injection with Access Key Creation for Lateral Movement (IAM-018)** | Attach an inline policy with administrative permissions to another IAM user and create access keys for them to gain programmatic access with elevated privileges |
| **Managed Policy Attachment with Trust Policy Hijacking for Privilege Escalation (IAM-019)** | Attach administrative managed policies to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
| **Policy Version Override with Trust Policy Hijacking for Privilege Escalation (IAM-020)** | Create a new version of a customer-managed policy attached to a role with administrative permissions and modify its trust policy to assume it, without prior assume-role access |
| **Inline Policy Injection with Trust Policy Hijacking for Privilege Escalation (IAM-021)** | Add an inline policy with administrative permissions to a role and modify its trust policy to allow yourself to assume it, gaining elevated privileges without prior assume-role access |
| **Lambda Function Creation with Privileged Role (LAMBDA-001)** | Create a Lambda function with a privileged IAM role and invoke it to execute code with that role's permissions |
| **Lambda Function Creation with Event Source Trigger (LAMBDA-002)** | Create a Lambda function with a privileged IAM role and an event source mapping to trigger it automatically, executing code with the role's permissions |
| **Lambda Function Code Injection (LAMBDA-003)** | Modify the code of an existing Lambda function to execute arbitrary commands with the function's execution role permissions |
| **Lambda Function Code Injection with Direct Invocation (LAMBDA-004)** | Modify the code of an existing Lambda function and invoke it directly to execute arbitrary commands with the function's execution role permissions |
| **Lambda Function Code Injection with Resource Policy Grant (LAMBDA-005)** | Modify the code of an existing Lambda function and grant yourself invocation permission via its resource-based policy to execute code with the function's execution role |
| **Lambda Function Creation with Resource Policy Invocation (LAMBDA-006)** | Create a Lambda function with a privileged IAM role and grant yourself invocation permission via its resource-based policy to execute code with the role's permissions |
| **SageMaker Notebook Creation with Privileged Role (SAGEMAKER-001)** | Create a SageMaker notebook instance with a privileged IAM role to execute arbitrary code with the role's permissions via the Jupyter environment |
| **SageMaker Training Job Creation with Privileged Role (SAGEMAKER-002)** | Create a SageMaker training job with a privileged IAM role to execute arbitrary container code with the role's permissions |
| **SageMaker Processing Job Creation with Privileged Role (SAGEMAKER-003)** | Create a SageMaker processing job with a privileged IAM role to execute arbitrary container code with the role's permissions |
| **SageMaker Presigned Notebook URL for Privilege Escalation (SAGEMAKER-004)** | Generate a presigned URL to access an existing SageMaker notebook instance and execute code with its execution role's permissions |
| **SageMaker Notebook Lifecycle Config Injection (SAGEMAKER-005)** | Inject a malicious lifecycle configuration into an existing SageMaker notebook to execute code with the notebook's execution role during startup |
| **SSM Session Access for EC2 Role Credentials (SSM-001)** | Start an SSM session on an EC2 instance to access its attached role credentials through IMDS |
| **SSM Send Command for EC2 Role Credentials (SSM-002)** | Execute commands on an EC2 instance via SSM Run Command to access its attached role credentials through IMDS |
| **Role Assumption for Privilege Escalation (STS-001)** | Assume IAM roles with elevated permissions by exploiting bidirectional trust between the starting principal and the target role |
These tools enable workflows such as:
- Asking an AI assistant to identify privilege escalation paths in a specific AWS account
@@ -0,0 +1,124 @@
---
title: "Findings Triage"
description: "Track finding review status and team notes in Prowler Cloud."
---
import { VersionBadge } from "/snippets/version-badge.mdx"
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.32.0" />
Findings Triage lets teams track review status and notes for individual findings in Prowler Cloud. Use it to record investigation state, remediation work, accepted risk, or false positive decisions without leaving the Findings workflow.
<SubscriptionBanner />
## What Is Findings Triage?
Findings Triage adds a **Triage** status and team note workflow to individual finding rows. It is available from:
- Expanded rows in **Finding Groups**
- Standalone finding tables
- Finding and resource detail drawers, including related findings tables
Finding Groups rows do not show triage controls because a group row represents several findings. Expand a group to work with each affected resource.
![Findings Triage Table](/images/prowler-app/findings-triage/findings-triage-table.png)
## Required Permissions
To update triage statuses and notes, the user role must have the **Manage Scans** permission. For more information, see [Role-Based Access Control (RBAC)](/user-guide/tutorials/prowler-app-rbac).
Users without this permission can still see existing triage context when it is available, but cannot change statuses or save notes.
## Triage Statuses
The status selector includes manual statuses. Prowler also sets automatic statuses after scans.
| Status | Type | Use It When |
| --- | --- | --- |
| **Open** | Manual | A failed finding has not been reviewed yet. A failed finding with no saved triage state also appears as **Open**. |
| **Under Review** | Manual | A team is investigating the finding. |
| **Remediating** | Manual | Work is in progress to fix the finding. |
| **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. |
| **False Positive** | Manual | The finding does not apply and should be muted. |
| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. |
| **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. |
![Findings Triage Status Selector](/images/prowler-app/findings-triage/findings-triage-status-dropdown.png)
Resolved and Reopened are not manual selector options.
These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot.
## Change a Triage Status
<Steps>
<Step title="Open Findings">
Go to **Findings** in Prowler Cloud.
</Step>
<Step title="Select an individual finding">
Expand a Finding Group, open a resource findings table, or use a standalone finding row.
</Step>
<Step title="Open the triage selector">
In the **Triage** column, click the current status.
</Step>
<Step title="Choose a status">
Select **Open**, **Under Review**, **Remediating**, **Risk Accepted**, or **False Positive**.
</Step>
</Steps>
Changing a finding to **Risk Accepted** or **False Positive** will mute the finding. Prowler asks for confirmation and creates a mute rule for the finding.
## Add or Edit a Triage Note
Triage notes are visible only to the team in the current organization. Each note supports up to 500 characters.
<Steps>
<Step title="Open the finding actions menu">
On an individual finding row, click the actions menu.
</Step>
<Step title="Open the note modal">
Click **Add Triage Note**. If a note already exists, click **Open note**.
</Step>
<Step title="Set status and note text">
Optionally change the status, then write the note.
</Step>
<Step title="Save changes">
Click **Save changes**.
</Step>
</Steps>
![Findings Triage Note Modal](/images/prowler-app/findings-triage/findings-triage-note-modal.png)
To remove an existing note, clear the note text and save the change.
## Mutelist Behavior
Findings Triage uses Mutelist when a status means the finding should be muted:
- **Risk Accepted** creates a mute rule because the team accepts the finding as a known risk.
- **False Positive** creates a mute rule because the finding should not count as an active issue.
Use [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) to review, disable, or delete mute rules created through this workflow. For pattern-based muting, use [Advanced Mutelist](/user-guide/tutorials/prowler-app-mute-findings).
<Warning>
Muting a finding does not fix the underlying configuration. Review the finding before using **Risk Accepted** or **False Positive**.
</Warning>
## Troubleshooting
### Triage controls do not appear
Make sure the row is an individual finding row. Finding Groups rows do not show triage controls. Expand a group to see affected resources and their triage controls.
### Changes cannot be saved
Confirm that the user role has **Manage Scans** permission. Self-hosted Prowler App does not support Findings Triage writes.
### Resolved or Reopened is missing from the selector
This is expected. Prowler sets **Resolved** and **Reopened** automatically from scan result changes.
### Risk Accepted or False Positive muted a finding
This is expected. Those statuses create a mute rule through Mutelist.
@@ -40,6 +40,11 @@ Follow these steps to edit a user of your account:
<img src="/images/prowler-app/rbac/user_edit_details.png" alt="Edit User Details" width="700" />
<Note>
Users can edit their own account details. Editing another user's account details requires the **Invite and Manage Users** or **admin** permission.
</Note>
#### Removing a User
Follow these steps to remove a user of your account:
@@ -39,6 +39,8 @@
"rolesanywhere:ListTagsForResource",
"rolesanywhere:ListTrustAnchors",
"s3:GetAccountPublicAccessBlock",
"s3:GetObjectAcl",
"s3:ListBucket",
"shield:DescribeProtection",
"shield:GetSubscriptionState",
"securityhub:BatchImportFindings",
+13 -6
View File
@@ -2,10 +2,19 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.32.0] (Prowler UNRELEASED)
## [5.32.1] (Prowler UNRELEASED)
### 🐞 Fixed
- `KeyError: 'MANUAL'` crash while rendering the compliance summary table (e.g. CIS Microsoft 365) when a framework has manual, checks-less requirements with a Level 1/Level 2 profile; `MANUAL` findings are now skipped in the PASS/FAIL section tally instead of raising [(#11822)](https://github.com/prowler-cloud/prowler/issues/11822)
---
## [5.32.0] (Prowler v5.32.0)
### 🚀 Added
- `exchange_application_access_policy_restricts_mailbox_apps` check for M365 provider, verifying every service principal with Microsoft Graph application-level Exchange mailbox permissions is restricted by an Exchange Online Application Access Policy, preventing tenant-wide mailbox access by unscoped applications [(#11247)](https://github.com/prowler-cloud/prowler/pull/11247)
- Per-requirement configuration validation for compliance frameworks via `ConfigRequirements`, so a requirement is reported as FAIL when its configurable checks ran with a configuration too loose to satisfy it (applied across all compliance outputs: CSV, OCSF, and console tables) [(#11669)](https://github.com/prowler-cloud/prowler/pull/11669)
- `entra_conditional_access_policy_explicitly_targets_azure_devops` check for M365 provider, verifying at least one enabled Conditional Access policy explicitly includes the Azure DevOps cloud application instead of relying on a broad "All cloud apps" policy [(#11182)](https://github.com/prowler-cloud/prowler/pull/11182)
- `entra_conditional_access_policy_no_exclusion_gaps` check for M365 provider, verifying every user, group, role, or application excluded from an enabled Conditional Access policy stays in scope of another enabled policy [(#11577)](https://github.com/prowler-cloud/prowler/pull/11577)
@@ -25,12 +34,15 @@ All notable changes to the **Prowler SDK** are documented in this file.
- AWS Bedrock AgentCore privilege escalation paths in the IAM privilege escalation checks, covering Runtime, Harness, Code Interpreter and Custom Browser [(#11726)](https://github.com/prowler-cloud/prowler/pull/11726)
- `--scan-secrets-validate` flag and `aws.secrets_validate` configuration option to optionally validate the secrets discovered by the secret-scanning checks against the provider APIs; secrets confirmed to be live are reported as critical [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
- `apigateway_restapi_no_secrets_in_stage_variables` check for AWS provider, scanning API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens [(#11188)](https://github.com/prowler-cloud/prowler/pull/11188)
- `s3_bucket_object_public` check for AWS provider, spot-checking a configurable sample of object ACLs in each bucket and flagging objects granted to the AllUsers or AuthenticatedUsers groups; disabled by default and opted into via the `s3_bucket_object_public_enabled` configuration option [(#9517)](https://github.com/prowler-cloud/prowler/pull/9517)
- Azure provider now supports `--azure-resource-group` to scope resource-level checks to specific resource groups across all accessible subscriptions [(#10657)](https://github.com/prowler-cloud/prowler/pull/10657)
### 🔄 Changed
- Replaced the `detect-secrets` library with [Kingfisher](https://github.com/mongodb/kingfisher) as the engine for the secret-scanning checks; scans run fully offline by default and obvious placeholder values are no longer reported as findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
- Removed the `detect_secrets_plugins` configuration option, which is no longer used by the new secret-scanning engine [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
- `awslambda_function_no_secrets_in_code` now supports a `secrets_ignore_files` audit-config option to skip files inside the deployment package by glob pattern (e.g. `*.deps.json`), suppressing .NET dependency-manifest false positives without masking real secrets [(#11222)](https://github.com/prowler-cloud/prowler/pull/11222)
- AWS scans for EBS snapshots, Backup recovery points, CloudWatch log groups, Lambda functions, ECS task definitions, and CodeArtifact packages now support configurable resource analysis limits via `aws.max_scanned_resources_per_service`; limits are disabled by default and only positive values cap analyzed resources [(#11228)](https://github.com/prowler-cloud/prowler/pull/11228)
### 🐞 Fixed
@@ -44,10 +56,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- GitHub default branch protection checks now evaluate repository rulesets in addition to classic branch protection, avoiding false positives for repositories that enforce protection through rulesets [(#11723)](https://github.com/prowler-cloud/prowler/pull/11723)
- Okta, Alibaba Cloud and OpenStack scan-config sections are now validated against a registered schema instead of being silently accepted, so their configurable thresholds (session/idle timeouts, retention days, image-sharing and secret-scanning settings) log a warning and fall back to the built-in default whenever a value is out of range [(#11725)](https://github.com/prowler-cloud/prowler/pull/11725)
### 🔄 Changed
- AWS scans for EBS snapshots, Backup recovery points, CloudWatch log groups, Lambda functions, ECS task definitions, and CodeArtifact packages now support configurable resource analysis limits via `aws.max_scanned_resources_per_service`; limits are disabled by default and only positive values cap analyzed resources [(#11228)](https://github.com/prowler-cloud/prowler/pull/11228)
---
## [5.31.1] (Prowler v5.31.1)
@@ -323,7 +331,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878)
- 8 Gmail attachment safety and spoofing protection checks for Google Workspace provider using the Cloud Identity Policy API [(#10980)](https://github.com/prowler-cloud/prowler/pull/10980)
- `bedrock_prompt_encrypted_with_cmk` check for AWS provider [(#10905)](https://github.com/prowler-cloud/prowler/pull/10905)
### 🔄 Changed
- Azure Network Watcher flow log checks now require workspace-backed Traffic Analytics for `network_flow_log_captured_sent` and align metadata with VNet-compatible flow log guidance [(#10645)](https://github.com/prowler-cloud/prowler/pull/10645)
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.32.0"
prowler_version = "5.33.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+12
View File
@@ -483,6 +483,18 @@ aws:
# Minimum retention period in hours for Kinesis streams
min_kinesis_stream_retention_hours: 168 # 7 days
# AWS S3 Configuration
# aws.s3_bucket_object_public
# This check performs a spot-check by sampling object ACLs within a bucket, so
# it is disabled by default. For complete coverage, rely on s3_bucket_acl_prohibited
# which enforces BucketOwnerEnforced Object Ownership (AWS's recommended approach).
# Set s3_bucket_object_public_enabled to True to opt in.
s3_bucket_object_public_enabled: False
# Maximum number of objects to list per bucket (upper bound for the sampling pool)
s3_bucket_object_public_max_objects: 100
# Number of objects to randomly sample from the listed pool and inspect ACLs for
s3_bucket_object_public_sample_size: 3
# AWS CodeBuild Configuration
# aws.codebuild_project_uses_allowed_github_organizations
codebuild_github_allowed_organizations:
+28
View File
@@ -458,3 +458,31 @@ class AWSProviderConfig(ProviderConfigBase):
le=8760,
description="Hours of Kinesis stream retention. Range: 24..8760 (1 day .. 1 year).",
)
# --- S3 --------------------------------------------------------------
s3_bucket_object_public_enabled: Optional[bool] = Field(
default=None,
description=(
"Enable the s3_bucket_object_public spot-check, which samples object "
"ACLs per bucket. Disabled by default because it lists and reads object "
"ACLs, which is expensive on large buckets."
),
)
s3_bucket_object_public_max_objects: Optional[int] = Field(
default=None,
ge=1,
le=1000,
description=(
"Max objects to list per bucket as the sampling pool. Range: 1..1000 "
"(ListObjectsV2 returns at most 1000 keys per page)."
),
)
s3_bucket_object_public_sample_size: Optional[int] = Field(
default=None,
ge=1,
le=1000,
description=(
"Number of objects sampled from the listed pool for ACL inspection. "
"Range: 1..1000. Must be positive to avoid a no-op or invalid sample."
),
)
@@ -395,6 +395,12 @@ def accumulate_group_status(
) -> None:
"""Count a finding once per group, upgrading a counted PASS to FAIL on conflict (mutates ``counts``/``seen``)."""
previous = seen.get(index)
if status == "MANUAL":
# MANUAL findings come from manual, checks-less requirements and are
# informational only: they have no PASS/FAIL/Muted column in the section
# tally, so counting them would raise KeyError on counts[status] += 1.
# Skip them (an unexpected status still raises loudly below).
return
if previous is None:
seen[index] = status
counts[status] += 1
@@ -0,0 +1,37 @@
{
"Provider": "aws",
"CheckID": "s3_bucket_object_public",
"CheckTitle": "Spot-check S3 bucket objects for public ACLs",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Effects/Data Exposure"
],
"ServiceName": "s3",
"SubServiceName": "",
"ResourceIdTemplate": "arn:partition:s3:::resource",
"Severity": "low",
"ResourceType": "AwsS3Bucket",
"Description": "Spot-checks a configurable sample of objects in each S3 bucket and flags any whose ACL grants access to the AllUsers or AuthenticatedUsers groups. This is a sampling-based check, not a comprehensive audit, so public objects outside the sample can be missed. It is disabled by default and must be enabled via the s3_bucket_object_public_enabled configuration flag.",
"Risk": "Public objects can be accessed by anyone on the internet, potentially leaking sensitive data. A bucket can appear private at the bucket-policy level while still containing individual objects with public ACL grants.",
"RelatedUrl": "",
"Remediation": {
"Code": {
"CLI": "aws s3api put-object-acl --bucket <bucket_name> --key <object_key> --acl private",
"NativeIaC": "",
"Other": "",
"Terraform": ""
},
"Recommendation": {
"Text": "For complete coverage, enable the s3_bucket_acl_prohibited check, which enforces the BucketOwnerEnforced Object Ownership setting (AWS's recommended approach since April 2023) and prevents public object ACLs entirely. Use this spot-check as a supplementary tool for manual assessments.",
"Url": "https://hub.prowler.com/check/s3_bucket_object_public"
}
},
"Categories": [
"internet-exposed"
],
"DependsOn": [],
"RelatedTo": [
"s3_bucket_acl_prohibited"
],
"Notes": "Disabled by default. Configure s3_bucket_object_public_enabled, s3_bucket_object_public_max_objects, and s3_bucket_object_public_sample_size in the Prowler configuration. Because only a sample of objects is inspected, a PASS does not guarantee the bucket is free of public objects; use s3_bucket_acl_prohibited for full assurance."
}
@@ -0,0 +1,82 @@
from typing import List
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.s3.s3_client import s3_client
# ACL grantee groups that make an object effectively public. AllUsers is anyone on
# the internet; AuthenticatedUsers is any authenticated AWS principal (any account).
PUBLIC_ACL_URIS = {
"http://acs.amazonaws.com/groups/global/AllUsers",
"http://acs.amazonaws.com/groups/global/AuthenticatedUsers",
}
class s3_bucket_object_public(Check):
"""Spot-check a sample of S3 bucket objects for public ACL grants."""
def execute(self) -> List[Check_Report_AWS]:
"""Evaluate sampled object ACLs for AllUsers/AuthenticatedUsers grants.
Returns:
List[Check_Report_AWS]: One report per sampled bucket (empty when the
check is disabled via configuration).
"""
findings = []
if not s3_client.audit_config.get("s3_bucket_object_public_enabled", False):
return findings
for bucket in s3_client.buckets.values():
sampling = bucket.object_sampling
# Sampling is populated by the service layer only when the check is
# enabled; skip any bucket that was not sampled.
if sampling is None or not sampling.performed:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=bucket)
if sampling.error_code is not None:
report.status = "MANUAL"
if sampling.error_code == "AccessDenied":
report.status_extended = (
f"Access Denied when spot-checking objects in bucket "
f"{bucket.name}."
)
else:
report.status_extended = (
f"Could not spot-check objects in bucket {bucket.name}: "
f"{sampling.error_message}."
)
elif sampling.is_empty:
report.status = "PASS"
report.status_extended = f"S3 Bucket {bucket.name} is empty."
else:
public_objects = [
obj.key
for obj in sampling.objects
if any(
grantee.type == "Group" and grantee.URI in PUBLIC_ACL_URIS
for grantee in obj.grantees
)
]
sampled = len(sampling.objects)
if public_objects:
report.status = "FAIL"
report.status_extended = (
f"S3 Bucket {bucket.name} has public objects detected in "
f"spot-check sample of {sampled} objects: "
f"{', '.join(public_objects)}."
)
else:
report.status = "PASS"
report.status_extended = (
f"No public objects detected in spot-check sample of "
f"{sampled} objects in bucket {bucket.name}. For complete "
f"assurance, ensure ACLs are disabled via Object Ownership "
f"settings."
)
findings.append(report)
return findings
@@ -36,6 +36,10 @@ class S3(AWSService):
self.__threading_call__(
self._get_bucket_notification_configuration, self.buckets.values()
)
# Object-level ACL sampling is expensive and opt-in, so only run it when
# the s3_bucket_object_public check is explicitly enabled in the config.
if self.audit_config.get("s3_bucket_object_public_enabled", False):
self.__threading_call__(self._get_public_objects, self.buckets.values())
def _list_buckets(self, provider):
logger.info("S3 - Listing buckets...")
@@ -487,6 +491,69 @@ class S3(AWSService):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_public_objects(self, bucket):
logger.info("S3 - Spot-checking bucket objects for public ACLs...")
max_objects = self.audit_config.get("s3_bucket_object_public_max_objects", 100)
sample_size = self.audit_config.get("s3_bucket_object_public_sample_size", 3)
# Guard against misconfigured non-positive values: a zero sample size would
# raise ZeroDivisionError and a negative one would silently sample nothing.
if not isinstance(max_objects, int) or max_objects <= 0:
max_objects = 100
if not isinstance(sample_size, int) or sample_size <= 0:
sample_size = 3
sampling = BucketObjectSampling(performed=True)
regional_client = None
try:
regional_client = self.regional_clients[bucket.region]
contents = regional_client.list_objects_v2(
Bucket=bucket.name, MaxKeys=max_objects
).get("Contents", [])
if not contents:
sampling.is_empty = True
bucket.object_sampling = sampling
return
all_keys = [obj["Key"] for obj in contents]
# Deterministic, evenly-spaced sampling so findings are reproducible
# across scans instead of flipping between PASS/FAIL with a random sample.
if len(all_keys) <= sample_size:
sample_keys = all_keys
else:
step = len(all_keys) // sample_size
sample_keys = [all_keys[i * step] for i in range(sample_size)]
for key in sample_keys:
acl = regional_client.get_object_acl(Bucket=bucket.name, Key=key)
grantees = []
for grant in acl.get("Grants", []):
grant_grantee = grant.get("Grantee", {})
grantee = ACL_Grantee(type=grant_grantee.get("Type", ""))
grantee.display_name = grant_grantee.get("DisplayName")
grantee.ID = grant_grantee.get("ID")
grantee.URI = grant_grantee.get("URI")
grantee.permission = grant.get("Permission")
grantees.append(grantee)
sampling.objects.append(ObjectACL(key=key, grantees=grantees))
bucket.object_sampling = sampling
except ClientError as error:
sampling.error_code = error.response["Error"]["Code"]
sampling.error_message = str(error)
bucket.object_sampling = sampling
region = regional_client.region if regional_client else bucket.region
logger.warning(
f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
sampling.error_code = error.__class__.__name__
sampling.error_message = str(error)
bucket.object_sampling = sampling
region = regional_client.region if regional_client else bucket.region
logger.error(
f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _head_bucket(self, bucket_name):
logger.info("S3 - Checking if bucket exists...")
try:
@@ -654,6 +721,19 @@ class PublicAccessBlock(BaseModel):
restrict_public_buckets: bool
class ObjectACL(BaseModel):
key: str
grantees: List[ACL_Grantee] = Field(default_factory=list)
class BucketObjectSampling(BaseModel):
performed: bool = False
is_empty: bool = False
objects: List[ObjectACL] = Field(default_factory=list)
error_code: Optional[str] = None
error_message: Optional[str] = None
class AccessPoint(BaseModel):
arn: str
account_id: str
@@ -703,3 +783,4 @@ class Bucket(BaseModel):
lifecycle: List[LifeCycleRule] = Field(default_factory=list)
replication_rules: List[ReplicationRule] = Field(default_factory=list)
notification_config: Dict = Field(default_factory=dict)
object_sampling: Optional[BucketObjectSampling] = None
+67 -2
View File
@@ -16,6 +16,7 @@ from azure.identity import (
DefaultAzureCredential,
InteractiveBrowserCredential,
)
from azure.mgmt.resource import ResourceManagementClient
from azure.mgmt.subscription import SubscriptionClient
from colorama import Fore, Style
from msgraph import GraphServiceClient
@@ -104,6 +105,7 @@ class AzureProvider(Provider):
_region_config: AzureRegionConfig
_locations: dict
_mutelist: AzureMutelist
_resource_groups: dict[str, list[str]]
# TODO: this is not optional, enforce for all providers
audit_metadata: Audit_Metadata
@@ -123,6 +125,7 @@ class AzureProvider(Provider):
mutelist_content: dict = None,
client_id: str = None,
client_secret: str = None,
resource_groups: list = [],
):
"""
Initializes the Azure provider.
@@ -142,6 +145,7 @@ class AzureProvider(Provider):
mutelist_content (dict): The mutelist content.
client_id (str): The Azure client ID.
client_secret (str): The Azure client secret.
resource_groups (list): List of resource group names.
Returns:
None
@@ -206,7 +210,7 @@ class AzureProvider(Provider):
... managed_identity_auth=False,
... region="AzureUSGovernment",
... )
- Subscriptions: rowler is multisubscription, which means that is going to scan all the subscriptions is able to list. If you only assign permissions to one subscription, it is going to scan a single one.
- Subscriptions: Prowler is multisubscription, which means that is going to scan all the subscriptions is able to list. If you only assign permissions to one subscription, it is going to scan a single one.
Prowler also allows you to specify the subscriptions you want to scan by passing a list of subscription IDs.
>>> AzureProvider(
... az_cli_auth=False,
@@ -215,6 +219,11 @@ class AzureProvider(Provider):
... managed_identity_auth=False,
... subscription_ids=["XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"],
... )
- Resource Groups: Prowler allows you to narrow the scan to specific resource groups.
>>> AzureProvider(
... az_cli_auth=True,
... resource_groups=["rg-production", "rg-staging"],
... )
"""
logger.info("Setting Azure provider ...")
@@ -272,6 +281,8 @@ class AzureProvider(Provider):
# TODO: should we keep this here or within the identity?
self._locations = self.get_locations()
self._resource_groups = self.validate_resource_groups(resource_groups)
# Audit Config
if config_content:
self._audit_config = config_content
@@ -337,6 +348,11 @@ class AzureProvider(Provider):
"""Mutelist object associated with this Azure provider."""
return self._mutelist
@property
def resource_groups(self) -> dict[str, list[str]]:
"""Mapping of subscription name to the list of resource groups to scan within it."""
return self._resource_groups
# TODO: this should be moved to the argparse, if not we need to enforce it from the Provider
# previously was using the AzureException
@staticmethod
@@ -439,7 +455,7 @@ class AzureProvider(Provider):
"""Azure credentials information.
This method prints the Azure Tenant Domain, Azure Tenant ID, Azure Region,
Azure Subscriptions, Azure Identity Type, and Azure Identity ID.
Azure Subscriptions, Azure Resource Groups, Azure Identity Type, and Azure Identity ID.
Args:
None
@@ -455,6 +471,7 @@ class AzureProvider(Provider):
f"Azure Tenant Domain: {Fore.YELLOW}{self._identity.tenant_domain}{Style.RESET_ALL} Azure Tenant ID: {Fore.YELLOW}{self._identity.tenant_ids[0]}{Style.RESET_ALL}",
f"Azure Region: {Fore.YELLOW}{self.region_config.name}{Style.RESET_ALL}",
f"Azure Subscriptions: {Fore.YELLOW}{printed_subscriptions}{Style.RESET_ALL}",
f"Azure Resource Groups: {Fore.YELLOW}{sorted({rg for rgs in self._resource_groups.values() for rg in rgs}) if any(self._resource_groups.values()) else ('NONE (no matching resource groups found)' if self._resource_groups else 'ALL')}{Style.RESET_ALL}",
f"Azure Identity Type: {Fore.YELLOW}{self._identity.identity_type}{Style.RESET_ALL} Azure Identity ID: {Fore.YELLOW}{self._identity.identity_id}{Style.RESET_ALL}",
]
report_title = (
@@ -1102,6 +1119,54 @@ class AzureProvider(Provider):
return set(chain.from_iterable(locations.values()))
def validate_resource_groups(self, resource_groups: list) -> dict[str, list[str]]:
resource_groups = [r.strip() for r in resource_groups if r and r.strip()]
if not resource_groups:
return {}
rg_map = {
subscription_id: [] for subscription_id in self._identity.subscriptions
}
credentials = self.session
for subscription_id, display_name in self._identity.subscriptions.items():
try:
rg_client = ResourceManagementClient(
credentials,
subscription_id,
base_url=self._region_config.base_url,
credential_scopes=self._region_config.credential_scopes,
)
existing_rgs = {
rg.name.lower(): rg.name for rg in rg_client.resource_groups.list()
}
except Exception as e:
logger.warning(
f"Could not list resource groups for subscription '{display_name}' "
f"({subscription_id}): {e}. Skipping resource group filtering for this subscription."
)
continue
for rg in resource_groups:
real_name = existing_rgs.get(rg.lower())
if real_name:
rg_map[subscription_id].append(real_name)
for rg in resource_groups:
if not any(rg.lower() == r.lower() for rgs in rg_map.values() for r in rgs):
logger.warning(
f"Resource group '{rg}' was not found in any subscription. "
"Please check the resource group name and try again."
)
if not any(rgs for rgs in rg_map.values()):
logger.warning(
f"None of the provided resource groups {resource_groups} were found "
"in any subscription. Please check the resource group names and try again."
)
return rg_map
@staticmethod
def validate_static_credentials(
tenant_id: str = None,
@@ -53,6 +53,16 @@ def init_parser(self):
type=validate_azure_region,
help="Azure region from `az cloud list --output table`, by default AzureCloud",
)
# Resource Groups
azure_rg_subparser = azure_parser.add_argument_group("Resource Groups")
azure_rg_subparser.add_argument(
"--azure-resource-group",
"--azure-resource-groups",
nargs="+",
default=[],
dest="resource_groups",
help="Azure Resource Group names to scope the scan to specific groups.",
)
def validate_azure_region(region):
@@ -26,6 +26,7 @@ class AzureService:
)
self.subscriptions = provider.identity.subscriptions
self.resource_groups = provider.resource_groups
self.locations = provider.locations
self.audit_config = provider.audit_config
self.fixer_config = provider.fixer_config
@@ -49,6 +50,26 @@ class AzureService:
return results
def list_with_rg_scope(self, subscription_id, list_all_fn, list_by_rg_fn):
if not self.resource_groups:
return list(list_all_fn())
resource_groups = self.resource_groups.get(subscription_id, [])
if not resource_groups:
logger.info(
f"No valid resource groups for subscription {subscription_id}, skipping."
)
return []
output = []
for resource_group in resource_groups:
try:
output += list(list_by_rg_fn(resource_group_name=resource_group))
except Exception as error:
logger.warning(
f"Subscription ID: {subscription_id} -- Resource Group: {resource_group} -- "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return output
def __set_clients__(self, identity, session, service, region_config):
clients = {}
try:
@@ -17,7 +17,11 @@ class AISearch(AzureService):
for subscription, client in self.clients.items():
try:
aisearch_services.update({subscription: {}})
aisearch_services_list = client.services.list_by_subscription()
aisearch_services_list = self.list_with_rg_scope(
subscription,
client.services.list_by_subscription,
client.services.list_by_resource_group,
)
for aisearch_service in aisearch_services_list:
aisearch_services[subscription].update(
{
@@ -19,8 +19,12 @@ class AKS(AzureService):
for subscription_id, client in self.clients.items():
try:
clusters_list = client.managed_clusters.list()
clusters.update({subscription_id: {}})
clusters_list = self.list_with_rg_scope(
subscription_id,
client.managed_clusters.list,
client.managed_clusters.list_by_resource_group,
)
for cluster in clusters_list:
if getattr(cluster, "kubernetes_version", None):
@@ -131,7 +131,11 @@ class APIM(AzureService):
for subscription, client in self.clients.items():
try:
instances.update({subscription: []})
apim_instances = client.api_management_service.list()
apim_instances = self.list_with_rg_scope(
subscription,
client.api_management_service.list,
client.api_management_service.list_by_resource_group,
)
for instance in apim_instances:
workspace_id = self._get_log_analytics_workspace_id(
@@ -22,8 +22,12 @@ class App(AzureService):
for subscription_id, client in self.clients.items():
try:
apps_list = client.web_apps.list()
apps.update({subscription_id: {}})
apps_list = self.list_with_rg_scope(
subscription_id,
client.web_apps.list,
client.web_apps.list_by_resource_group,
)
for app in apps_list:
# Filter function apps
@@ -117,8 +121,12 @@ class App(AzureService):
for subscription_id, client in self.clients.items():
try:
functions_list = client.web_apps.list()
functions.update({subscription_id: {}})
functions_list = self.list_with_rg_scope(
subscription_id,
client.web_apps.list,
client.web_apps.list_by_resource_group,
)
for function in functions_list:
# Filter function apps
@@ -17,8 +17,12 @@ class AppInsights(AzureService):
for subscription_id, client in self.clients.items():
try:
components_list = client.components.list()
components.update({subscription_id: {}})
components_list = self.list_with_rg_scope(
subscription_id,
client.components.list,
client.components.list_by_resource_group,
)
for component in components_list:
components[subscription_id].update(
@@ -19,8 +19,12 @@ class ContainerRegistry(AzureService):
registries = {}
for subscription, client in self.clients.items():
try:
registries_list = client.registries.list()
registries.update({subscription: {}})
registries_list = self.list_with_rg_scope(
subscription,
client.registries.list,
client.registries.list_by_resource_group,
)
for registry in registries_list:
resource_group = self._get_resource_group(registry.id)
@@ -18,8 +18,13 @@ class CosmosDB(AzureService):
accounts = {}
for subscription, client in self.clients.items():
try:
accounts_list = client.database_accounts.list()
accounts.update({subscription: []})
accounts_list = self.list_with_rg_scope(
subscription,
client.database_accounts.list,
client.database_accounts.list_by_resource_group,
)
for account in accounts_list:
accounts[subscription].append(
Account(
@@ -38,8 +38,13 @@ class Databricks(AzureService):
for subscription, client in self.clients.items():
try:
workspaces[subscription] = {}
workspaces_list = self.list_with_rg_scope(
subscription,
client.workspaces.list_by_subscription,
client.workspaces.list_by_resource_group,
)
for workspace in client.workspaces.list_by_subscription():
for workspace in workspaces_list:
workspace_parameters = getattr(workspace, "parameters", None)
workspace_managed_disk_encryption = getattr(
getattr(
@@ -230,8 +230,10 @@ class Defender(AzureService):
iot_security_solutions = {}
for subscription_id, client in self.clients.items():
try:
iot_security_solutions_list = (
client.iot_security_solution.list_by_subscription()
iot_security_solutions_list = self.list_with_rg_scope(
subscription_id,
client.iot_security_solution.list_by_subscription,
client.iot_security_solution.list_by_resource_group,
)
iot_security_solutions.update({subscription_id: {}})
for iot_security_solution in iot_security_solutions_list:
@@ -267,8 +269,13 @@ class Defender(AzureService):
for subscription_id, client in self.clients.items():
try:
jit_policies[subscription_id] = {}
policies = client.jit_network_access_policies.list()
for policy in policies:
policies_list = self.list_with_rg_scope(
subscription_id,
client.jit_network_access_policies.list,
client.jit_network_access_policies.list_by_resource_group,
)
for policy in policies_list:
vm_ids = set()
for vm in getattr(policy, "virtual_machines", []):
vm_ids.add(vm.id)
@@ -35,7 +35,11 @@ class KeyVault(AzureService):
for subscription, client in self.clients.items():
try:
key_vaults[subscription] = []
vaults_list = list(client.vaults.list_by_subscription())
vaults_list = self.list_with_rg_scope(
subscription,
client.vaults.list_by_subscription,
client.vaults.list_by_resource_group,
)
if not vaults_list:
continue
@@ -19,8 +19,12 @@ class MySQL(AzureService):
servers = {}
for subscription_id, client in self.clients.items():
try:
servers_list = client.servers.list()
servers.update({subscription_id: {}})
servers_list = self.list_with_rg_scope(
subscription_id,
client.servers.list,
client.servers.list_by_resource_group,
)
for server in servers_list:
backup = getattr(server, "backup", None)
ha = getattr(server, "high_availability", None)
@@ -24,8 +24,13 @@ class Network(AzureService):
security_groups = {}
for subscription, client in self.clients.items():
try:
security_groups_list = self.list_with_rg_scope(
subscription,
client.network_security_groups.list_all,
client.network_security_groups.list,
)
security_groups.update({subscription: []})
security_groups_list = client.network_security_groups.list_all()
for security_group in security_groups_list:
security_groups[subscription].append(
SecurityGroup(
@@ -64,8 +69,8 @@ class Network(AzureService):
network_watchers = {}
for subscription, client in self.clients.items():
try:
network_watchers.update({subscription: []})
network_watchers_list = client.network_watchers.list_all()
network_watchers.update({subscription: []})
for network_watcher in network_watchers_list:
flow_logs = self._get_flow_logs(
subscription, network_watcher.name, network_watcher.id
@@ -164,8 +169,13 @@ class Network(AzureService):
bastion_hosts = {}
for subscription, client in self.clients.items():
try:
bastion_hosts_list = self.list_with_rg_scope(
subscription,
client.bastion_hosts.list,
client.bastion_hosts.list_by_resource_group,
)
bastion_hosts.update({subscription: []})
bastion_hosts_list = client.bastion_hosts.list()
for bastion_host in bastion_hosts_list:
bastion_hosts[subscription].append(
BastionHost(
@@ -186,8 +196,13 @@ class Network(AzureService):
public_ip_addresses = {}
for subscription, client in self.clients.items():
try:
public_ip_addresses_list = self.list_with_rg_scope(
subscription,
client.public_ip_addresses.list_all,
client.public_ip_addresses.list,
)
public_ip_addresses.update({subscription: []})
public_ip_addresses_list = client.public_ip_addresses.list_all()
for public_ip_address in public_ip_addresses_list:
public_ip_addresses[subscription].append(
PublicIp(
@@ -207,13 +222,17 @@ class Network(AzureService):
def _get_virtual_networks(self):
logger.info("Network - Getting Virtual Networks...")
virtual_networks = {}
for subscription, client in self.clients.items():
for subscription_id, client in self.clients.items():
try:
virtual_networks[subscription] = []
vnet_list = client.virtual_networks.list_all()
for vnet in vnet_list:
virtual_networks[subscription_id] = []
virtual_networks_list = self.list_with_rg_scope(
subscription_id,
client.virtual_networks.list_all,
client.virtual_networks.list,
)
for virtual_network in virtual_networks_list:
subnets = []
for subnet in getattr(vnet, "subnets", []) or []:
for subnet in getattr(virtual_network, "subnets", []) or []:
nsg = getattr(subnet, "network_security_group", None)
subnets.append(
VNetSubnet(
@@ -222,20 +241,20 @@ class Network(AzureService):
nsg_id=getattr(nsg, "id", None) if nsg else None,
)
)
virtual_networks[subscription].append(
virtual_networks[subscription_id].append(
VirtualNetwork(
id=vnet.id,
name=vnet.name,
location=vnet.location,
id=virtual_network.id,
name=virtual_network.name,
location=virtual_network.location,
enable_ddos_protection=getattr(
vnet, "enable_ddos_protection", False
virtual_network, "enable_ddos_protection", False
),
subnets=subnets,
)
)
except Exception as error:
logger.error(
f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
f"Subscription ID: {subscription_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return virtual_networks
@@ -18,8 +18,8 @@ class Policy(AzureService):
for subscription_id, client in self.clients.items():
try:
policy_assigments_list = client.policy_assignments.list()
policy_assigments.update({subscription_id: {}})
policy_assigments_list = client.policy_assignments.list()
for policy_assigment in policy_assigments_list:
policy_assigments[subscription_id].update(
@@ -19,8 +19,13 @@ class PostgreSQL(AzureService):
flexible_servers = {}
for subscription, client in self.clients.items():
try:
flexible_servers_list = self.list_with_rg_scope(
subscription,
client.servers.list,
client.servers.list_by_resource_group,
)
flexible_servers.update({subscription: []})
flexible_servers_list = client.servers.list()
for postgresql_server in flexible_servers_list:
# Isolate each server: a failure collecting one server must
# not abort collection of the remaining servers in the
@@ -56,9 +56,14 @@ class Recovery(AzureService):
try:
vaults_dict: dict[str, dict[str, BackupVault]] = {}
for subscription_id, client in self.clients.items():
vaults = client.vaults.list_by_subscription_id()
vaults_list = self.list_with_rg_scope(
subscription_id,
client.vaults.list_by_subscription_id,
client.vaults.list_by_resource_group,
)
vaults_dict[subscription_id] = {}
for vault in vaults:
for vault in vaults_list:
vault_obj = BackupVault(
id=vault.id,
name=vault.name,
@@ -18,8 +18,13 @@ class SQLServer(AzureService):
sql_servers = {}
for subscription, client in self.clients.items():
try:
sql_servers_list = self.list_with_rg_scope(
subscription,
client.servers.list,
client.servers.list_by_resource_group,
)
sql_servers.update({subscription: []})
sql_servers_list = client.servers.list()
for sql_server in sql_servers_list:
resource_group = self._get_resource_group(sql_server.id)
auditing_policies = self._get_server_blob_auditing_policies(
@@ -20,8 +20,13 @@ class Storage(AzureService):
storage_accounts = {}
for subscription, client in self.clients.items():
try:
storage_accounts_list = self.list_with_rg_scope(
subscription,
client.storage_accounts.list,
client.storage_accounts.list_by_resource_group,
)
storage_accounts.update({subscription: []})
storage_accounts_list = client.storage_accounts.list()
for storage_account in storage_accounts_list:
parts = storage_account.id.split("/")
if "resourceGroups" in parts:
@@ -22,8 +22,12 @@ class VirtualMachines(AzureService):
for subscription_id, client in self.clients.items():
try:
virtual_machines_list = client.virtual_machines.list_all()
virtual_machines.update({subscription_id: {}})
virtual_machines_list = self.list_with_rg_scope(
subscription_id,
client.virtual_machines.list_all,
client.virtual_machines.list,
)
for vm in virtual_machines_list:
storage_profile = getattr(vm, "storage_profile", None)
@@ -155,8 +159,12 @@ class VirtualMachines(AzureService):
for subscription_id, client in self.clients.items():
try:
disks_list = client.disks.list()
disks.update({subscription_id: {}})
disks_list = self.list_with_rg_scope(
subscription_id,
client.disks.list,
client.disks.list_by_resource_group,
)
for disk in disks_list:
vms_attached = []
@@ -202,9 +210,13 @@ class VirtualMachines(AzureService):
vm_scale_sets = {}
for subscription_id, client in self.clients.items():
try:
scale_sets = client.virtual_machine_scale_sets.list_all()
vm_scale_sets[subscription_id] = {}
for scale_set in scale_sets:
scale_sets_list = self.list_with_rg_scope(
subscription_id,
client.virtual_machine_scale_sets.list_all,
client.virtual_machine_scale_sets.list,
)
for scale_set in scale_sets_list:
backend_pools = []
nic_configs = []
virtual_machine_profile = getattr(
+1
View File
@@ -407,6 +407,7 @@ class Provider(ABC):
tenant_id=arguments.tenant_id,
region=arguments.azure_region,
subscription_ids=arguments.subscription_id,
resource_groups=arguments.resource_groups,
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
@@ -1002,6 +1002,31 @@ class M365PowerShell(PowerShellSession):
json_parse=True,
)
def get_application_access_policies(self) -> dict:
"""
Get Exchange Online Application Access Policies.
Retrieves all Exchange Online Application Access Policies.
Returns:
dict: Application access policies in JSON format.
Example:
>>> get_application_access_policies()
[
{
"Identity": "Policy1",
"AppId": "12345678-1234-1234-1234-123456789012",
"AccessRight": "RestrictAccess",
"Description": "Restrict mailbox access"
}
]
"""
return self.execute(
"Get-ApplicationAccessPolicy | ConvertTo-Json -Depth 10",
json_parse=True,
)
def get_user_account_status(self) -> dict:
"""
Get User Account Status.
@@ -1,4 +1,5 @@
import asyncio
import importlib
import json
from asyncio import gather
from datetime import datetime, timezone
@@ -9,9 +10,6 @@ from uuid import UUID
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
RunHuntingQueryPostRequestBody,
)
from msgraph.generated.users.users_request_builder import UsersRequestBuilder
from pydantic.v1 import BaseModel, validator
@@ -19,6 +17,10 @@ from prowler.lib.logger import logger
from prowler.providers.m365.lib.service.service import M365Service
from prowler.providers.m365.m365_provider import M365Provider
run_hunting_query_body = importlib.import_module(
"msgraph.generated.security.microsoft_graph_security_run_hunting_query."
"run_hunting_query_post_request_body"
)
# Sentinel identifiers used in Conditional Access ``conditions.users``
# collections that do not correspond to real directory objects and must not be
# resolved against Graph. Shared by the resolver below and the check that reads
@@ -84,6 +86,7 @@ class Entra(M365Service):
self.tenant_domain = provider.identity.tenant_domain
self.tenant_id = getattr(provider.identity, "tenant_id", None)
self.user_registration_details_error: Optional[str] = None
self.exchange_mailbox_permission_service_principals_error: Optional[str] = None
attributes = loop.run_until_complete(
gather(
self._get_authorization_policy(),
@@ -98,6 +101,7 @@ class Entra(M365Service):
self._get_authentication_method_configurations(),
self._get_service_principals(),
self._get_app_registrations(),
self._get_exchange_mailbox_permission_service_principals(),
)
)
@@ -115,6 +119,9 @@ class Entra(M365Service):
] = attributes[9]
self.service_principals: Dict[str, "ServicePrincipal"] = attributes[10]
self.app_registrations: Dict[str, "AppRegistration"] = attributes[11]
self.exchange_mailbox_permission_service_principals: Dict[
str, "ServicePrincipal"
] = attributes[12]
self.user_accounts_status = {}
# Resolve directory-object identifiers referenced by Conditional Access
@@ -1054,7 +1061,9 @@ OAuthAppInfo
| project OAuthAppId, AppName, AppStatus, PrivilegeLevel, Permissions,
ServicePrincipalId, IsAdminConsented, LastUsedTime, AppOrigin
"""
request_body = RunHuntingQueryPostRequestBody(query=query)
request_body = run_hunting_query_body.RunHuntingQueryPostRequestBody(
query=query
)
result = await self.client.security.microsoft_graph_security_run_hunting_query.post(
request_body
@@ -1382,6 +1391,112 @@ OAuthAppInfo
)
return service_principals
async def _get_exchange_mailbox_permission_service_principals(self):
"""Retrieve service principals with Exchange mailbox Graph app roles."""
logger.info(
"Entra - Getting service principals with Exchange mailbox permissions..."
)
self.exchange_mailbox_permission_service_principals_error = None
service_principals = {}
graph_service_principal = None
candidate_service_principals = []
try:
sp_response = await self.client.service_principals.get()
while sp_response:
for sp in getattr(sp_response, "value", []) or []:
app_id = getattr(sp, "app_id", None)
if app_id == MICROSOFT_GRAPH_APP_ID:
graph_service_principal = sp
continue
if not getattr(sp, "account_enabled", True):
continue
raw_owner = getattr(sp, "app_owner_organization_id", None)
app_owner_org_id = str(raw_owner).lower() if raw_owner else None
if app_owner_org_id in MICROSOFT_FIRST_PARTY_TENANT_IDS:
continue
candidate_service_principals.append(sp)
next_link = getattr(sp_response, "odata_next_link", None)
if not next_link:
break
sp_response = await self.client.service_principals.with_url(
next_link
).get()
if graph_service_principal is None:
return service_principals
graph_service_principal_id = getattr(graph_service_principal, "id", None)
exchange_app_roles = {}
for role in getattr(graph_service_principal, "app_roles", []) or []:
role_value = getattr(role, "value", "") or ""
allowed_member_types = getattr(role, "allowed_member_types", []) or []
if (
role_value in EXCHANGE_MAILBOX_GRAPH_PERMISSIONS
and "Application" in allowed_member_types
):
exchange_app_roles[str(getattr(role, "id", ""))] = role_value
if not graph_service_principal_id or not exchange_app_roles:
return service_principals
for sp in candidate_service_principals:
assignments_response = (
await self.client.service_principals.by_service_principal_id(
sp.id
).app_role_assignments.get()
)
exchange_permissions = set()
while assignments_response:
for assignment in getattr(assignments_response, "value", []) or []:
resource_id = str(getattr(assignment, "resource_id", ""))
app_role_id = str(getattr(assignment, "app_role_id", ""))
if resource_id == graph_service_principal_id:
permission = exchange_app_roles.get(app_role_id)
if permission:
exchange_permissions.add(permission)
next_link = getattr(assignments_response, "odata_next_link", None)
if not next_link:
break
assignments_response = (
await self.client.service_principals.by_service_principal_id(
sp.id
)
.app_role_assignments.with_url(next_link)
.get()
)
if exchange_permissions:
raw_owner = getattr(sp, "app_owner_organization_id", None)
service_principals[sp.id] = ServicePrincipal(
id=sp.id,
name=getattr(sp, "display_name", "") or "",
app_id=getattr(sp, "app_id", "") or "",
app_owner_organization_id=(
str(raw_owner).lower() if raw_owner else None
),
account_enabled=getattr(sp, "account_enabled", True),
service_principal_type=getattr(
sp, "service_principal_type", "Application"
),
exchange_mailbox_permissions=sorted(exchange_permissions),
)
except Exception as error:
self.exchange_mailbox_permission_service_principals_error = (
f"{error.__class__.__name__}: {error}"
)
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return service_principals
async def _get_app_registrations(self) -> Dict[str, "AppRegistration"]:
"""Retrieve application registrations from Microsoft Entra.
@@ -2064,6 +2179,27 @@ TIER_0_ROLE_TEMPLATE_IDS = {
"e00e864a-17c5-4a4b-9c06-f5b95a8d5bd8", # Partner Tier2 Support
}
MICROSOFT_GRAPH_APP_ID = "00000003-0000-0000-c000-000000000000"
MICROSOFT_FIRST_PARTY_TENANT_IDS = {
"72f988bf-86f1-41af-91ab-2d7cd011db47",
"f8cdef31-a31e-4b4a-93e4-5f571e91255a",
}
EXCHANGE_MAILBOX_GRAPH_PERMISSIONS = {
"Calendars.Read",
"Calendars.ReadWrite",
"Contacts.Read",
"Contacts.ReadWrite",
"Mail.Read",
"Mail.ReadBasic",
"Mail.ReadBasic.All",
"Mail.ReadWrite",
"Mail.Send",
"MailboxSettings.Read",
"MailboxSettings.ReadWrite",
}
class ServicePrincipal(BaseModel):
"""Model representing a Microsoft Entra ID service principal.
@@ -2096,6 +2232,9 @@ class ServicePrincipal(BaseModel):
password_credentials: List[PasswordCredential] = []
key_credentials: List[KeyCredential] = []
directory_role_template_ids: List[str] = []
account_enabled: bool = True
service_principal_type: str = "Application"
exchange_mailbox_permissions: List[str] = []
sp_owner_ids: List[str] = []
app_owner_ids: List[str] = []
@@ -0,0 +1,41 @@
{
"Provider": "m365",
"CheckID": "exchange_application_access_policy_restricts_mailbox_apps",
"CheckTitle": "Apps with Exchange mailbox permissions must be scoped via Application Access Policy",
"CheckType": [],
"ServiceName": "exchange",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "collaboration",
"Description": "**Microsoft 365 Exchange Online** applications with mailbox access permissions should be restricted using **Application Access Policies**.\n\nThis check evaluates whether applications granted Exchange-related Microsoft Graph application permissions are scoped using Exchange Online `ApplicationAccessPolicy` objects.",
"Risk": "Applications with unrestricted Exchange mailbox permissions may gain tenant-wide mailbox access. Without **Application Access Policies**, compromised or over-privileged applications can read or manipulate mail across all mailboxes, leading to unauthorized access, data exfiltration, phishing, and loss of confidentiality and integrity.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/graph/auth-limit-mailbox-access",
"https://learn.microsoft.com/en-us/powershell/module/exchange/new-applicationaccesspolicy",
"https://learn.microsoft.com/en-us/powershell/module/exchange/get-applicationaccesspolicy",
"https://learn.microsoft.com/en-us/graph/api/resources/serviceprincipal?view=graph-rest-1.0",
"https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-approleassignments?view=graph-rest-1.0"
],
"Remediation": {
"Code": {
"CLI": "New-ApplicationAccessPolicy -AppId <AppId> -PolicyScopeGroupId <Group> -AccessRight RestrictAccess -Description \"Restrict mailbox access\"",
"NativeIaC": "",
"Other": "1. Connect to Exchange Online PowerShell\n2. Run Get-ApplicationAccessPolicy to review existing policies\n3. Identify applications with Exchange-related Graph application permissions\n4. Create an Application Access Policy for each required application\n5. Scope mailbox access using a dedicated mail-enabled security group",
"Terraform": ""
},
"Recommendation": {
"Text": "Restrict applications with Exchange mailbox permissions using **Application Access Policies**. Apply least privilege by limiting mailbox scope to only required users or groups. Regularly review app permissions and remove unused or excessive mailbox access.",
"Url": "https://hub.prowler.com/check/exchange_application_access_policy_restricts_mailbox_apps"
}
},
"Categories": [
"identity-access",
"email-security"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,99 @@
import importlib
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
exchange_client = importlib.import_module(
"prowler.providers.m365.services.exchange.exchange_client"
).exchange_client
class exchange_application_access_policy_restricts_mailbox_apps(Check):
"""
Check if applications with Exchange mailbox permissions
are restricted using Exchange Application Access Policies.
"""
def execute(self) -> List[CheckReportM365]:
findings = []
application_access_policies = exchange_client.application_access_policies
if application_access_policies is None:
report = CheckReportM365(
metadata=self.metadata(),
resource=exchange_client.organization_config,
resource_name="Exchange Online",
resource_id="ExchangeOnlineTenant",
)
report.status = "MANUAL"
report.status_extended = (
"Exchange Online PowerShell is unavailable. "
"Enable Exchange Online PowerShell credentials to evaluate "
"Application Access Policies."
)
findings.append(report)
return findings
mailbox_permission_collection_error = getattr(
entra_client,
"exchange_mailbox_permission_service_principals_error",
None,
)
if isinstance(mailbox_permission_collection_error, str):
report = CheckReportM365(
metadata=self.metadata(),
resource=exchange_client.organization_config,
resource_name="Exchange Online",
resource_id="ExchangeOnlineTenant",
)
report.status = "MANUAL"
report.status_extended = (
"Microsoft Graph mailbox permission collection failed. "
"Manually verify whether applications with Exchange mailbox "
"permissions are restricted using Application Access Policies."
)
findings.append(report)
return findings
policy_app_ids = {
policy.app_id.lower()
for policy in application_access_policies
if getattr(policy, "app_id", None)
and getattr(policy, "access_right", None) == "RestrictAccess"
}
service_principals = (
entra_client.exchange_mailbox_permission_service_principals.values()
)
for service_principal in service_principals:
report = CheckReportM365(
metadata=self.metadata(),
resource=service_principal,
resource_name=service_principal.name,
resource_id=service_principal.id,
)
permissions = ", ".join(service_principal.exchange_mailbox_permissions)
if service_principal.app_id.lower() in policy_app_ids:
report.status = "PASS"
report.status_extended = (
f"Service principal '{service_principal.name}' "
f"({service_principal.app_id}) "
"has Exchange mailbox permissions "
f"({permissions}) and is restricted using an Application "
"Access Policy."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Service principal '{service_principal.name}' "
f"({service_principal.app_id}) "
"has Exchange mailbox permissions "
f"({permissions}) but is not restricted using an Application "
"Access Policy."
)
findings.append(report)
return findings
@@ -43,6 +43,7 @@ class Exchange(M365Service):
self.role_assignment_policies = []
self.mailbox_audit_properties = []
self.shared_mailboxes = []
self.application_access_policies = None
self.mailboxes = None
if self.powershell:
@@ -56,6 +57,9 @@ class Exchange(M365Service):
self.role_assignment_policies = self._get_role_assignment_policies()
self.mailbox_audit_properties = self._get_mailbox_audit_properties()
self.shared_mailboxes = self._get_shared_mailboxes()
self.application_access_policies = (
self._get_application_access_policies()
)
self.mailboxes = self._get_mailboxes()
self.powershell.close()
@@ -366,6 +370,53 @@ class Exchange(M365Service):
)
return shared_mailboxes
def _get_application_access_policies(self):
"""
Get Exchange Online Application Access Policies.
Returns:
Optional[list[ApplicationAccessPolicy]]: List of application access
policies, or None if the PowerShell command failed.
"""
logger.info("Microsoft365 - Getting application access policies...")
application_access_policies = []
try:
policies_data = self.powershell.get_application_access_policies()
if not policies_data:
return application_access_policies
if isinstance(policies_data, dict):
policies_data = [policies_data]
for policy in policies_data:
if policy:
application_access_policies.append(
ApplicationAccessPolicy(
identity=policy.get("Identity", ""),
app_id=policy.get("AppId", ""),
access_right=policy.get(
"AccessRight",
"",
),
description=policy.get(
"Description",
"",
),
)
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}"
f"[{error.__traceback__.tb_lineno}]: {error}"
)
return None
return application_access_policies
def _get_mailboxes(self) -> Optional[list["Mailbox"]]:
"""
Get all recipient-facing mailboxes from Exchange Online.
@@ -554,6 +605,17 @@ class SharedMailbox(BaseModel):
identity: str
class ApplicationAccessPolicy(BaseModel):
"""
Model for Exchange Online Application Access Policy.
"""
identity: str
app_id: str
access_right: str
description: str
class Mailbox(BaseModel):
"""
Model for an Exchange Online recipient-facing mailbox.
+1 -1
View File
@@ -125,7 +125,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.14"
version = "5.32.0"
version = "5.33.0"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -351,6 +351,37 @@ class Test_accumulate_group_status:
with pytest.raises(KeyError):
accumulate_group_status(0, "Muted", counts, {})
def test_manual_status_is_ignored_not_counted(self):
# A MANUAL finding (from a manual, checks-less requirement) has no
# PASS/FAIL/Muted column: it must be skipped, not raise KeyError, and
# not appear in the tally. Regression test for the M365 CIS compliance
# crash "KeyError: 'MANUAL'" (issue #11822).
counts = {"FAIL": 0, "PASS": 0}
seen = {}
accumulate_group_status(0, "MANUAL", counts, seen)
assert counts == {"FAIL": 0, "PASS": 0}
assert seen == {}
def test_manual_mixed_with_pass_and_fail(self):
# MANUAL findings interleaved with real PASS/FAIL ones only skip
# themselves; the PASS/FAIL tally is unaffected.
counts = {"FAIL": 0, "PASS": 0}
seen = {}
accumulate_group_status(0, "MANUAL", counts, seen)
accumulate_group_status(1, "PASS", counts, seen)
accumulate_group_status(2, "FAIL", counts, seen)
accumulate_group_status(3, "MANUAL", counts, seen)
assert counts == {"FAIL": 1, "PASS": 1}
def test_manual_ignored_on_counts_with_muted_key(self):
# MANUAL is skipped regardless of the counts shape (e.g. the universal
# table's PASS/FAIL/Muted buckets), never creating a "MANUAL" key.
counts = {"FAIL": 0, "PASS": 0, "Muted": 0}
seen = {}
accumulate_group_status(0, "MANUAL", counts, seen)
assert counts == {"FAIL": 0, "PASS": 0, "Muted": 0}
assert "MANUAL" not in counts
class Test_apply_config_status:
def test_none_config_status_keeps_finding(self):
@@ -0,0 +1,302 @@
from unittest import mock
from boto3 import client
from botocore.exceptions import ClientError
from moto import mock_aws
from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider
CHECK_MODULE = (
"prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public"
)
ENABLED_CONFIG = {
"s3_bucket_object_public_enabled": True,
"s3_bucket_object_public_max_objects": 100,
"s3_bucket_object_public_sample_size": 3,
}
class Test_s3_bucket_object_public:
@mock_aws
def test_check_disabled_by_default_returns_no_findings(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
s3_client_us_east_1.create_bucket(Bucket="bucket-disabled")
from prowler.providers.aws.services.s3.s3_service import S3
# No audit_config -> check disabled by default
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
s3_service = S3(aws_provider)
with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service):
from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import (
s3_bucket_object_public,
)
check = s3_bucket_object_public()
result = check.execute()
assert result == []
@mock_aws
def test_no_buckets_returns_no_findings(self):
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG
)
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
s3_service = S3(aws_provider)
with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service):
from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import (
s3_bucket_object_public,
)
check = s3_bucket_object_public()
result = check.execute()
assert result == []
@mock_aws
def test_bucket_empty_passes(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "bucket-empty"
s3_client_us_east_1.create_bucket(Bucket=bucket_name)
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG
)
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
s3_service = S3(aws_provider)
with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service):
from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import (
s3_bucket_object_public,
)
check = s3_bucket_object_public()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
f"S3 Bucket {bucket_name} is empty."
)
assert result[0].resource_id == bucket_name
assert result[0].region == AWS_REGION_US_EAST_1
@mock_aws
def test_bucket_with_only_private_objects_passes(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "bucket-private-objects"
s3_client_us_east_1.create_bucket(Bucket=bucket_name)
s3_client_us_east_1.put_object(
Bucket=bucket_name, Key="private-1.txt", Body=b"x"
)
s3_client_us_east_1.put_object(
Bucket=bucket_name, Key="private-2.txt", Body=b"x"
)
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG
)
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
s3_service = S3(aws_provider)
with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service):
from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import (
s3_bucket_object_public,
)
check = s3_bucket_object_public()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "No public objects detected in spot-check sample of" in (
result[0].status_extended
)
assert bucket_name in result[0].status_extended
assert (
"For complete assurance, ensure ACLs are disabled via "
"Object Ownership settings."
) in result[0].status_extended
@mock_aws
def test_bucket_with_public_object_fails(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "bucket-public-object"
public_key = "public.txt"
s3_client_us_east_1.create_bucket(Bucket=bucket_name)
s3_client_us_east_1.put_object(Bucket=bucket_name, Key=public_key, Body=b"x")
s3_client_us_east_1.put_object_acl(
Bucket=bucket_name, Key=public_key, ACL="public-read"
)
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG
)
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
s3_service = S3(aws_provider)
with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service):
from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import (
s3_bucket_object_public,
)
check = s3_bucket_object_public()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert public_key in result[0].status_extended
assert (
f"S3 Bucket {bucket_name} has public objects detected in "
"spot-check sample of"
) in result[0].status_extended
@mock_aws
def test_bucket_with_authenticated_users_object_fails(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "bucket-authenticated-object"
public_key = "authenticated.txt"
s3_client_us_east_1.create_bucket(Bucket=bucket_name)
s3_client_us_east_1.put_object(Bucket=bucket_name, Key=public_key, Body=b"x")
s3_client_us_east_1.put_object_acl(
Bucket=bucket_name, Key=public_key, ACL="authenticated-read"
)
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG
)
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
s3_service = S3(aws_provider)
with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service):
from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import (
s3_bucket_object_public,
)
check = s3_bucket_object_public()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert public_key in result[0].status_extended
@mock_aws
def test_access_denied_on_list_objects_reports_manual(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "bucket-access-denied"
s3_client_us_east_1.create_bucket(Bucket=bucket_name)
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG
)
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
s3_service = S3(aws_provider)
# Simulate AccessDenied when sampling and re-run sampling for the bucket
regional_client = mock.MagicMock()
regional_client.region = AWS_REGION_US_EAST_1
regional_client.list_objects_v2.side_effect = ClientError(
{"Error": {"Code": "AccessDenied", "Message": "denied"}},
"ListObjectsV2",
)
s3_service.regional_clients[AWS_REGION_US_EAST_1] = regional_client
bucket = next(iter(s3_service.buckets.values()))
bucket.object_sampling = None
s3_service._get_public_objects(bucket)
with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service):
from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import (
s3_bucket_object_public,
)
check = s3_bucket_object_public()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].status_extended == (
f"Access Denied when spot-checking objects in bucket "
f"{bucket_name}."
)
@mock_aws
def test_other_client_error_reports_manual(self):
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "bucket-other-error"
s3_client_us_east_1.create_bucket(Bucket=bucket_name)
from prowler.providers.aws.services.s3.s3_service import S3
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1], audit_config=ENABLED_CONFIG
)
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
s3_service = S3(aws_provider)
regional_client = mock.MagicMock()
regional_client.region = AWS_REGION_US_EAST_1
regional_client.list_objects_v2.side_effect = ClientError(
{"Error": {"Code": "InternalError", "Message": "boom"}},
"ListObjectsV2",
)
s3_service.regional_clients[AWS_REGION_US_EAST_1] = regional_client
bucket = next(iter(s3_service.buckets.values()))
bucket.object_sampling = None
s3_service._get_public_objects(bucket)
with mock.patch(f"{CHECK_MODULE}.s3_client", new=s3_service):
from prowler.providers.aws.services.s3.s3_bucket_object_public.s3_bucket_object_public import (
s3_bucket_object_public,
)
check = s3_bucket_object_public()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
f"Could not spot-check objects in bucket {bucket_name}"
) in result[0].status_extended
@@ -642,3 +642,47 @@ class Test_S3_Service:
assert s3control.access_points[arn].public_access_block.ignore_public_acls
assert s3control.access_points[arn].public_access_block.block_public_policy
assert s3control.access_points[arn].public_access_block.restrict_public_buckets
# Test S3 object ACL sampling is skipped unless the check is enabled
@mock_aws
def test_get_public_objects_disabled_by_default(self):
s3_client = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "test-bucket"
bucket_arn = f"arn:aws:s3:::{bucket_name}"
s3_client.create_bucket(Bucket=bucket_name)
s3_client.put_object(Bucket=bucket_name, Key="a.txt", Body=b"x")
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
s3 = S3(aws_provider)
assert s3.buckets[bucket_arn].object_sampling is None
# Test S3 object ACL sampling detects a public object when enabled
@mock_aws
def test_get_public_objects_detects_public_acl(self):
s3_client = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "test-bucket"
bucket_arn = f"arn:aws:s3:::{bucket_name}"
s3_client.create_bucket(Bucket=bucket_name)
s3_client.put_object(Bucket=bucket_name, Key="public.txt", Body=b"x")
s3_client.put_object_acl(
Bucket=bucket_name, Key="public.txt", ACL="public-read"
)
aws_provider = set_mocked_aws_provider(
[AWS_REGION_US_EAST_1],
audit_config={"s3_bucket_object_public_enabled": True},
)
s3 = S3(aws_provider)
sampling = s3.buckets[bucket_arn].object_sampling
assert sampling is not None
assert sampling.performed is True
assert sampling.is_empty is False
assert len(sampling.objects) == 1
assert sampling.objects[0].key == "public.txt"
assert any(
grantee.type == "Group"
and grantee.URI == "http://acs.amazonaws.com/groups/global/AllUsers"
for grantee in sampling.objects[0].grantees
)
+4
View File
@@ -9,6 +9,8 @@ from prowler.providers.azure.models import AzureIdentityInfo, AzureRegionConfig
AZURE_SUBSCRIPTION_ID = str(uuid4())
AZURE_SUBSCRIPTION_NAME = "Subscription Name"
AZURE_SUBSCRIPTION_DISPLAY = f"{AZURE_SUBSCRIPTION_NAME} ({AZURE_SUBSCRIPTION_ID})"
RESOURCE_GROUP = "rg"
RESOURCE_GROUP_LIST = [RESOURCE_GROUP, "rg2"]
# Azure Identity
IDENTITY_ID = "00000000-0000-0000-0000-000000000000"
@@ -30,6 +32,7 @@ def set_mocked_azure_provider(
audit_config: dict = None,
azure_region_config: AzureRegionConfig = AzureRegionConfig(),
locations: list = None,
resource_groups: dict = None,
) -> AzureProvider:
provider = MagicMock()
@@ -39,5 +42,6 @@ def set_mocked_azure_provider(
provider.identity = identity
provider.audit_config = audit_config
provider.region_config = azure_region_config
provider.resource_groups = resource_groups
return provider
@@ -552,6 +552,102 @@ class TestAzureProvider:
assert regions == expected_regions
class TestAzureProviderValidateResourceGroups:
@patch(
"prowler.providers.azure.azure_provider.AzureProvider.__init__",
return_value=None,
)
def _make_provider(self, _mock_init, subscriptions=None):
provider = AzureProvider()
provider._identity = MagicMock()
provider._identity.subscriptions = subscriptions or {str(uuid4()): "Sub"}
provider._session = MagicMock()
provider._region_config = MagicMock()
return provider
@patch("prowler.providers.azure.azure_provider.ResourceManagementClient")
def test_validate_resource_groups_exact_match(self, mock_rm_client):
provider = self._make_provider()
sub_name = list(provider._identity.subscriptions.keys())[0]
mock_rg = MagicMock()
mock_rg.name = "mygroup"
mock_resource_groups = MagicMock()
mock_resource_groups.list.return_value = [mock_rg]
mock_rm_client.return_value.resource_groups = mock_resource_groups
result = provider.validate_resource_groups(["mygroup"])
assert result[sub_name] == ["mygroup"]
@patch("prowler.providers.azure.azure_provider.ResourceManagementClient")
def test_validate_resource_groups_mixed_case(self, mock_rm_client):
provider = self._make_provider()
sub_name = list(provider._identity.subscriptions.keys())[0]
mock_rg = MagicMock()
mock_rg.name = "MyGroup"
mock_resource_groups = MagicMock()
mock_resource_groups.list.return_value = [mock_rg]
mock_rm_client.return_value.resource_groups = mock_resource_groups
result = provider.validate_resource_groups(["mygroup"])
assert result[sub_name] == ["MyGroup"]
mock_resource_groups.list.assert_called_once()
@patch("prowler.providers.azure.azure_provider.ResourceManagementClient")
def test_validate_resource_groups_multiple_rgs(self, mock_rm_client):
provider = self._make_provider()
sub_name = list(provider._identity.subscriptions.keys())[0]
rg1, rg2 = MagicMock(), MagicMock()
rg1.name = "rg1"
rg2.name = "rg2"
mock_resource_groups = MagicMock()
mock_resource_groups.list.return_value = [rg1, rg2]
mock_rm_client.return_value.resource_groups = mock_resource_groups
result = provider.validate_resource_groups(["rg1", "rg2"])
assert set(result[sub_name]) == {"rg1", "rg2"}
@patch("prowler.providers.azure.azure_provider.ResourceManagementClient")
def test_validate_resource_groups_not_found(self, mock_rm_client):
provider = self._make_provider()
sub_name = list(provider._identity.subscriptions.keys())[0]
mock_rg = MagicMock()
mock_rg.name = "existing"
mock_resource_groups = MagicMock()
mock_resource_groups.list.return_value = [mock_rg]
mock_rm_client.return_value.resource_groups = mock_resource_groups
result = provider.validate_resource_groups(["nonexistent"])
assert result[sub_name] == []
def test_validate_resource_groups_empty_input(self):
provider = self._make_provider()
result = provider.validate_resource_groups([])
assert result == {}
@patch("prowler.providers.azure.azure_provider.ResourceManagementClient")
def test_validate_resource_groups_strips_whitespace(self, mock_rm_client):
provider = self._make_provider()
sub_name = list(provider._identity.subscriptions.keys())[0]
mock_rg = MagicMock()
mock_rg.name = "rg-prod"
mock_resource_groups = MagicMock()
mock_resource_groups.list.return_value = [mock_rg]
mock_rm_client.return_value.resource_groups = mock_resource_groups
result = provider.validate_resource_groups([" rg-prod "])
assert result[sub_name] == ["rg-prod"]
class TestAzureProviderSetupIdentitySubscriptions:
"""Regression tests ensuring identity.subscriptions preserves every
subscription even when multiple Azure subscriptions share the same
@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from prowler.providers.azure.services.aisearch.aisearch_service import (
AISearch,
@@ -6,9 +6,13 @@ from prowler.providers.azure.services.aisearch.aisearch_service import (
)
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
RESOURCE_GROUP,
RESOURCE_GROUP_LIST,
set_mocked_azure_provider,
)
AISEARCH_SERVICE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.Search/searchServices/search1"
def mock_storage_get_aisearch_services(_):
return {
@@ -58,3 +62,121 @@ class Test_AISearch_Service:
assert aisearch.aisearch_services[AZURE_SUBSCRIPTION_ID][
"aisearch_service_id-1"
].public_network_access
class Test_AISearch_Service_get_aisearch_services:
def test_get_aisearch_services_no_resource_groups(self):
mock_service = MagicMock()
mock_service.id = AISEARCH_SERVICE_ID
mock_service.name = "search1"
mock_service.location = "westeurope"
mock_service.public_network_access = "Enabled"
mock_client = MagicMock()
mock_client.services.list_by_subscription.return_value = [mock_service]
with patch(
"prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services",
return_value={},
):
aisearch = AISearch(set_mocked_azure_provider())
aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aisearch.resource_groups = None
result = aisearch._get_aisearch_services()
mock_client.services.list_by_subscription.assert_called_once()
mock_client.services.list_by_resource_group.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert (
result[AZURE_SUBSCRIPTION_ID][AISEARCH_SERVICE_ID].public_network_access
is True
)
def test_get_aisearch_services_with_resource_group(self):
mock_service = MagicMock()
mock_service.id = AISEARCH_SERVICE_ID
mock_service.name = "search1"
mock_service.location = "westeurope"
mock_service.public_network_access = "Disabled"
mock_client = MagicMock()
mock_client.services.list_by_resource_group.return_value = [mock_service]
with patch(
"prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services",
return_value={},
):
aisearch = AISearch(set_mocked_azure_provider())
aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aisearch.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]}
result = aisearch._get_aisearch_services()
mock_client.services.list_by_resource_group.assert_called_once_with(
resource_group_name=RESOURCE_GROUP
)
mock_client.services.list_by_subscription.assert_not_called()
assert (
result[AZURE_SUBSCRIPTION_ID][AISEARCH_SERVICE_ID].public_network_access
is False
)
def test_get_aisearch_services_empty_resource_group_for_subscription(self):
mock_client = MagicMock()
with patch(
"prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services",
return_value={},
):
aisearch = AISearch(set_mocked_azure_provider())
aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aisearch.resource_groups = {AZURE_SUBSCRIPTION_ID: []}
result = aisearch._get_aisearch_services()
mock_client.services.list_by_resource_group.assert_not_called()
mock_client.services.list_by_subscription.assert_not_called()
assert result[AZURE_SUBSCRIPTION_ID] == {}
def test_get_aisearch_services_with_multiple_resource_groups(self):
mock_client = MagicMock()
mock_client.services = MagicMock()
mock_client.services.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services",
return_value={},
):
aisearch = AISearch(set_mocked_azure_provider())
aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aisearch.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST}
result = aisearch._get_aisearch_services()
assert mock_client.services.list_by_resource_group.call_count == 2
assert AZURE_SUBSCRIPTION_ID in result
def test_get_aisearch_services_with_mixed_case_resource_group(self):
mock_client = MagicMock()
mock_client.services = MagicMock()
mock_client.services.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services",
return_value={},
):
aisearch = AISearch(set_mocked_azure_provider())
aisearch.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aisearch.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]}
aisearch._get_aisearch_services()
mock_client.services.list_by_resource_group.assert_called_once_with(
resource_group_name="RG"
)
@@ -1,8 +1,10 @@
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from prowler.providers.azure.services.aks.aks_service import AKS, Cluster
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
RESOURCE_GROUP,
RESOURCE_GROUP_LIST,
set_mocked_azure_provider,
)
@@ -66,3 +68,128 @@ class Test_AKS_Service:
aks.clusters[AZURE_SUBSCRIPTION_ID]["cluster_id-1"].location == "westeurope"
)
assert aks.clusters[AZURE_SUBSCRIPTION_ID]["cluster_id-1"].rbac_enabled
class Test_AKS_get_clusters:
def test_get_clusters_no_resource_groups(self):
mock_cluster = MagicMock()
mock_cluster.id = "cluster_id-1"
mock_cluster.name = "cluster_name"
mock_cluster.fqdn = "public_fqdn"
mock_cluster.private_fqdn = "private_fqdn"
mock_cluster.location = "westeurope"
mock_cluster.kubernetes_version = "1.28.0"
mock_cluster.network_profile = None
mock_cluster.agent_pool_profiles = []
mock_cluster.enable_rbac = False
mock_client = MagicMock()
mock_client.managed_clusters.list.return_value = [mock_cluster]
with patch(
"prowler.providers.azure.services.aks.aks_service.AKS._get_clusters",
return_value={},
):
aks = AKS(set_mocked_azure_provider())
aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aks.resource_groups = None
result = aks._get_clusters()
mock_client.managed_clusters.list.assert_called_once()
mock_client.managed_clusters.list_by_resource_group.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert "cluster_id-1" in result[AZURE_SUBSCRIPTION_ID]
def test_get_clusters_with_resource_group(self):
mock_cluster = MagicMock()
mock_cluster.id = "cluster_id-1"
mock_cluster.name = "cluster_name"
mock_cluster.fqdn = "public_fqdn"
mock_cluster.private_fqdn = "private_fqdn"
mock_cluster.location = "westeurope"
mock_cluster.kubernetes_version = "1.28.0"
mock_cluster.network_profile = None
mock_cluster.agent_pool_profiles = []
mock_cluster.enable_rbac = False
mock_client = MagicMock()
mock_client.managed_clusters.list_by_resource_group.return_value = [
mock_cluster
]
with patch(
"prowler.providers.azure.services.aks.aks_service.AKS._get_clusters",
return_value={},
):
aks = AKS(set_mocked_azure_provider())
aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aks.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]}
result = aks._get_clusters()
mock_client.managed_clusters.list_by_resource_group.assert_called_once_with(
resource_group_name=RESOURCE_GROUP
)
mock_client.managed_clusters.list.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert "cluster_id-1" in result[AZURE_SUBSCRIPTION_ID]
def test_get_clusters_empty_resource_group_for_subscription(self):
mock_client = MagicMock()
with patch(
"prowler.providers.azure.services.aks.aks_service.AKS._get_clusters",
return_value={},
):
aks = AKS(set_mocked_azure_provider())
aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aks.resource_groups = {AZURE_SUBSCRIPTION_ID: []}
result = aks._get_clusters()
mock_client.managed_clusters.list_by_resource_group.assert_not_called()
mock_client.managed_clusters.list.assert_not_called()
assert result[AZURE_SUBSCRIPTION_ID] == {}
def test_get_clusters_with_multiple_resource_groups(self):
mock_client = MagicMock()
mock_client.managed_clusters = MagicMock()
mock_client.managed_clusters.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.aks.aks_service.AKS._get_clusters",
return_value={},
):
aks = AKS(set_mocked_azure_provider())
aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aks.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST}
result = aks._get_clusters()
assert mock_client.managed_clusters.list_by_resource_group.call_count == 2
assert AZURE_SUBSCRIPTION_ID in result
def test_get_clusters_with_mixed_case_resource_group(self):
mock_client = MagicMock()
mock_client.managed_clusters = MagicMock()
mock_client.managed_clusters.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.aks.aks_service.AKS._get_clusters",
return_value={},
):
aks = AKS(set_mocked_azure_provider())
aks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
aks.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]}
aks._get_clusters()
mock_client.managed_clusters.list_by_resource_group.assert_called_once_with(
resource_group_name="RG"
)
@@ -1,6 +1,6 @@
from datetime import timedelta
from unittest import TestCase, mock
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from azure.mgmt.loganalytics.models import Workspace
from azure.mgmt.monitor.models import DiagnosticSettingsResource
@@ -9,6 +9,8 @@ from azure.monitor.query import LogsQueryResult
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
AZURE_SUBSCRIPTION_NAME,
RESOURCE_GROUP,
RESOURCE_GROUP_LIST,
set_mocked_azure_provider,
)
@@ -16,7 +18,6 @@ from tests.providers.azure.azure_fixtures import (
APIM_INSTANCE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.ApiManagement/service/apim1"
APIM_INSTANCE_NAME = "apim1"
LOCATION = "West US"
RESOURCE_GROUP = "rg"
WORKSPACE_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourcegroups/rg/providers/microsoft.operationalinsights/workspaces/loganalytics"
WORKSPACE_CUSTOMER_ID = "12345678-1234-1234-1234-1234567890ab"
@@ -323,3 +324,168 @@ class Test_APIM_Service(TestCase):
instance = apim.instances[AZURE_SUBSCRIPTION_ID][0]
result = apim.get_llm_operations_logs(AZURE_SUBSCRIPTION_ID, instance)
self.assertEqual(result, [{"log": "data"}])
class Test_APIM_get_instances:
def test_get_instances_no_resource_groups(self):
mock_instance = MagicMock()
mock_instance.id = APIM_INSTANCE_ID
mock_instance.name = APIM_INSTANCE_NAME
mock_instance.location = LOCATION
mock_client = MagicMock()
mock_client.api_management_service.list.return_value = [mock_instance]
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with (
patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
):
from prowler.providers.azure.services.apim.apim_service import APIM
apim = APIM(set_mocked_azure_provider())
apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
apim.resource_groups = None
with patch.object(apim, "_get_log_analytics_workspace_id", return_value=None):
result = apim._get_instances()
mock_client.api_management_service.list.assert_called_once()
mock_client.api_management_service.list_by_resource_group.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert len(result[AZURE_SUBSCRIPTION_ID]) == 1
assert result[AZURE_SUBSCRIPTION_ID][0].id == APIM_INSTANCE_ID
def test_get_instances_with_resource_group(self):
mock_instance = MagicMock()
mock_instance.id = APIM_INSTANCE_ID
mock_instance.name = APIM_INSTANCE_NAME
mock_instance.location = LOCATION
mock_client = MagicMock()
mock_client.api_management_service.list_by_resource_group.return_value = [
mock_instance
]
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with (
patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
):
from prowler.providers.azure.services.apim.apim_service import APIM
apim = APIM(set_mocked_azure_provider())
apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
apim.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]}
with patch.object(apim, "_get_log_analytics_workspace_id", return_value=None):
result = apim._get_instances()
mock_client.api_management_service.list_by_resource_group.assert_called_once_with(
resource_group_name=RESOURCE_GROUP
)
mock_client.api_management_service.list.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert len(result[AZURE_SUBSCRIPTION_ID]) == 1
assert result[AZURE_SUBSCRIPTION_ID][0].name == APIM_INSTANCE_NAME
def test_get_instances_empty_resource_group_for_subscription(self):
mock_client = MagicMock()
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with (
patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
):
from prowler.providers.azure.services.apim.apim_service import APIM
apim = APIM(set_mocked_azure_provider())
apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
apim.resource_groups = {AZURE_SUBSCRIPTION_ID: []}
result = apim._get_instances()
mock_client.api_management_service.list_by_resource_group.assert_not_called()
mock_client.api_management_service.list.assert_not_called()
assert result[AZURE_SUBSCRIPTION_ID] == []
def test_get_instances_with_multiple_resource_groups(self):
mock_client = MagicMock()
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with (
patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
):
from prowler.providers.azure.services.apim.apim_service import APIM
apim = APIM(set_mocked_azure_provider())
apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
apim.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST}
with patch.object(apim, "_get_log_analytics_workspace_id", return_value=None):
result = apim._get_instances()
assert mock_client.api_management_service.list_by_resource_group.call_count == 2
assert AZURE_SUBSCRIPTION_ID in result
def test_get_instances_with_mixed_case_resource_group(self):
mock_client = MagicMock()
mock_provider = mock.MagicMock()
mock_provider.identity = mock.MagicMock()
with (
patch(
"prowler.providers.azure.azure_provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.apim.apim_service.APIM._get_instances",
return_value={},
),
):
from prowler.providers.azure.services.apim.apim_service import APIM
apim = APIM(set_mocked_azure_provider())
apim.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
apim.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]}
with patch.object(apim, "_get_log_analytics_workspace_id", return_value=None):
apim._get_instances()
mock_client.api_management_service.list_by_resource_group.assert_called_once_with(
resource_group_name="RG"
)
@@ -5,6 +5,8 @@ from azure.mgmt.web.models import ManagedServiceIdentity, SiteConfigResource
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
RESOURCE_GROUP,
RESOURCE_GROUP_LIST,
set_mocked_azure_provider,
)
@@ -244,3 +246,279 @@ class Test_App_Service:
].name
== "functionapp-1"
)
class Test_App_get_apps:
def test_get_apps_no_resource_groups(self):
mock_client = MagicMock()
mock_client.web_apps.list.return_value = []
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = None
result = app._get_apps()
mock_client.web_apps.list.assert_called_once()
mock_client.web_apps.list_by_resource_group.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
def test_get_apps_with_resource_group(self):
mock_client = MagicMock()
mock_client.web_apps.list_by_resource_group.return_value = []
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]}
result = app._get_apps()
mock_client.web_apps.list_by_resource_group.assert_called_once_with(
resource_group_name=RESOURCE_GROUP
)
mock_client.web_apps.list.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
def test_get_apps_empty_resource_group_for_subscription(self):
mock_client = MagicMock()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = {AZURE_SUBSCRIPTION_ID: []}
result = app._get_apps()
mock_client.web_apps.list_by_resource_group.assert_not_called()
mock_client.web_apps.list.assert_not_called()
assert result[AZURE_SUBSCRIPTION_ID] == {}
class Test_App_get_functions:
def test_get_functions_no_resource_groups(self):
mock_client = MagicMock()
mock_client.web_apps.list.return_value = []
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = None
result = app._get_functions()
mock_client.web_apps.list.assert_called_once()
mock_client.web_apps.list_by_resource_group.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
def test_get_functions_with_resource_group(self):
mock_client = MagicMock()
mock_client.web_apps.list_by_resource_group.return_value = []
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]}
result = app._get_functions()
mock_client.web_apps.list_by_resource_group.assert_called_once_with(
resource_group_name=RESOURCE_GROUP
)
mock_client.web_apps.list.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
def test_get_functions_empty_resource_group_for_subscription(self):
mock_client = MagicMock()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = {AZURE_SUBSCRIPTION_ID: []}
result = app._get_functions()
mock_client.web_apps.list_by_resource_group.assert_not_called()
mock_client.web_apps.list.assert_not_called()
assert result[AZURE_SUBSCRIPTION_ID] == {}
def test_get_apps_with_multiple_resource_groups(self):
mock_client = MagicMock()
mock_client.web_apps.list_by_resource_group.return_value = []
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST}
result = app._get_apps()
assert mock_client.web_apps.list_by_resource_group.call_count == 2
assert AZURE_SUBSCRIPTION_ID in result
def test_get_apps_with_mixed_case_resource_group(self):
mock_client = MagicMock()
mock_client.web_apps.list_by_resource_group.return_value = []
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]}
app._get_apps()
mock_client.web_apps.list_by_resource_group.assert_called_once_with(
resource_group_name="RG"
)
class Test_App_get_functions_extra:
def test_get_functions_with_multiple_resource_groups(self):
mock_client = MagicMock()
mock_client.web_apps.list_by_resource_group.return_value = []
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST}
result = app._get_functions()
assert mock_client.web_apps.list_by_resource_group.call_count == 2
assert AZURE_SUBSCRIPTION_ID in result
def test_get_functions_with_mixed_case_resource_group(self):
mock_client = MagicMock()
mock_client.web_apps.list_by_resource_group.return_value = []
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
):
from prowler.providers.azure.services.app.app_service import App
app = App(set_mocked_azure_provider())
app.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]}
app._get_functions()
mock_client.web_apps.list_by_resource_group.assert_called_once_with(
resource_group_name="RG"
)
@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from prowler.providers.azure.services.appinsights.appinsights_service import (
AppInsights,
@@ -6,6 +6,8 @@ from prowler.providers.azure.services.appinsights.appinsights_service import (
)
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
RESOURCE_GROUP,
RESOURCE_GROUP_LIST,
set_mocked_azure_provider,
)
@@ -54,3 +56,121 @@ class Test_AppInsights_Service:
appinsights.components[AZURE_SUBSCRIPTION_ID]["app_id-1"].location
== "westeurope"
)
class Test_AppInsights_get_components:
def test_get_components_no_resource_groups(self):
mock_component = MagicMock()
mock_component.app_id = "comp-app-id"
mock_component.id = "/subscriptions/sub/rg/appinsights"
mock_component.name = "ai-component"
mock_component.location = "westeurope"
mock_component.instrumentation_key = "ikey-123"
mock_client = MagicMock()
mock_client.components = MagicMock()
mock_client.components.list.return_value = [mock_component]
with patch(
"prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components",
return_value={},
):
app_insights = AppInsights(set_mocked_azure_provider())
app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app_insights.resource_groups = None
result = app_insights._get_components()
mock_client.components.list.assert_called_once()
mock_client.components.list_by_resource_group.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert "comp-app-id" in result[AZURE_SUBSCRIPTION_ID]
def test_get_components_with_resource_group(self):
mock_component = MagicMock()
mock_component.app_id = "comp-app-id"
mock_component.id = "/subscriptions/sub/rg/appinsights"
mock_component.name = "ai-component"
mock_component.location = "westeurope"
mock_component.instrumentation_key = "ikey-123"
mock_client = MagicMock()
mock_client.components = MagicMock()
mock_client.components.list_by_resource_group.return_value = [mock_component]
with patch(
"prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components",
return_value={},
):
app_insights = AppInsights(set_mocked_azure_provider())
app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app_insights.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]}
result = app_insights._get_components()
mock_client.components.list_by_resource_group.assert_called_once_with(
resource_group_name=RESOURCE_GROUP
)
mock_client.components.list.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert "comp-app-id" in result[AZURE_SUBSCRIPTION_ID]
def test_get_components_empty_resource_group_for_subscription(self):
mock_client = MagicMock()
mock_client.components = MagicMock()
with patch(
"prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components",
return_value={},
):
app_insights = AppInsights(set_mocked_azure_provider())
app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app_insights.resource_groups = {AZURE_SUBSCRIPTION_ID: []}
result = app_insights._get_components()
mock_client.components.list_by_resource_group.assert_not_called()
mock_client.components.list.assert_not_called()
assert result[AZURE_SUBSCRIPTION_ID] == {}
def test_get_components_with_multiple_resource_groups(self):
mock_client = MagicMock()
mock_client.components = MagicMock()
mock_client.components.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components",
return_value={},
):
app_insights = AppInsights(set_mocked_azure_provider())
app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app_insights.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST}
result = app_insights._get_components()
assert mock_client.components.list_by_resource_group.call_count == 2
assert AZURE_SUBSCRIPTION_ID in result
def test_get_components_with_mixed_case_resource_group(self):
mock_client = MagicMock()
mock_client.components = MagicMock()
mock_client.components.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components",
return_value={},
):
app_insights = AppInsights(set_mocked_azure_provider())
app_insights.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
app_insights.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]}
app_insights._get_components()
mock_client.components.list_by_resource_group.assert_called_once_with(
resource_group_name="RG"
)
@@ -3,6 +3,8 @@ from uuid import uuid4
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
RESOURCE_GROUP,
RESOURCE_GROUP_LIST,
set_mocked_azure_provider,
)
@@ -89,3 +91,208 @@ class TestContainerRegistryService:
assert monitor_setting["logs"][0]["enabled"] is True
assert monitor_setting["logs"][1]["category"] == "AdminLogs"
assert monitor_setting["logs"][1]["enabled"] is False
class Test_ContainerRegistry_get_registries:
def test_get_container_registries_no_resource_groups(self):
from unittest.mock import MagicMock, patch
mock_client = MagicMock()
mock_client.registries.list.return_value = []
mock_provider = MagicMock()
mock_provider.identity = MagicMock()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries",
return_value={},
),
):
from prowler.providers.azure.services.containerregistry.containerregistry_service import (
ContainerRegistry,
)
cr = ContainerRegistry(set_mocked_azure_provider())
cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cr.resource_groups = None
with patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client"
):
result = cr._get_container_registries()
mock_client.registries.list.assert_called_once()
mock_client.registries.list_by_resource_group.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
def test_get_container_registries_with_resource_group(self):
from unittest.mock import MagicMock, patch
mock_client = MagicMock()
mock_client.registries.list_by_resource_group.return_value = []
mock_provider = MagicMock()
mock_provider.identity = MagicMock()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries",
return_value={},
),
):
from prowler.providers.azure.services.containerregistry.containerregistry_service import (
ContainerRegistry,
)
cr = ContainerRegistry(set_mocked_azure_provider())
cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cr.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]}
with patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client"
):
result = cr._get_container_registries()
mock_client.registries.list_by_resource_group.assert_called_once_with(
resource_group_name=RESOURCE_GROUP
)
mock_client.registries.list.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
def test_get_container_registries_empty_resource_group_for_subscription(self):
from unittest.mock import MagicMock, patch
mock_client = MagicMock()
mock_provider = MagicMock()
mock_provider.identity = MagicMock()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries",
return_value={},
),
):
from prowler.providers.azure.services.containerregistry.containerregistry_service import (
ContainerRegistry,
)
cr = ContainerRegistry(set_mocked_azure_provider())
cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cr.resource_groups = {AZURE_SUBSCRIPTION_ID: []}
with patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client"
):
result = cr._get_container_registries()
mock_client.registries.list_by_resource_group.assert_not_called()
mock_client.registries.list.assert_not_called()
assert result[AZURE_SUBSCRIPTION_ID] == {}
def test_get_container_registries_with_multiple_resource_groups(self):
from unittest.mock import MagicMock, patch
mock_client = MagicMock()
mock_client.registries.list_by_resource_group.return_value = []
mock_provider = MagicMock()
mock_provider.identity = MagicMock()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries",
return_value={},
),
):
from prowler.providers.azure.services.containerregistry.containerregistry_service import (
ContainerRegistry,
)
cr = ContainerRegistry(set_mocked_azure_provider())
cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cr.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST}
with patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client"
):
result = cr._get_container_registries()
assert mock_client.registries.list_by_resource_group.call_count == len(
RESOURCE_GROUP_LIST
)
mock_client.registries.list.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
def test_get_container_registries_with_mixed_case_resource_group(self):
from unittest.mock import MagicMock, patch
mock_client = MagicMock()
mock_client.registries.list_by_resource_group.return_value = []
mock_provider = MagicMock()
mock_provider.identity = MagicMock()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.azure.services.monitor.monitor_service.Monitor",
new=MagicMock(),
),
patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.ContainerRegistry._get_container_registries",
return_value={},
),
):
from prowler.providers.azure.services.containerregistry.containerregistry_service import (
ContainerRegistry,
)
cr = ContainerRegistry(set_mocked_azure_provider())
cr.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cr.resource_groups = {AZURE_SUBSCRIPTION_ID: ["MyRegistry-RG"]}
with patch(
"prowler.providers.azure.services.containerregistry.containerregistry_service.monitor_client"
):
cr._get_container_registries()
mock_client.registries.list_by_resource_group.assert_called_once_with(
resource_group_name="MyRegistry-RG"
)
@@ -1,8 +1,10 @@
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from prowler.providers.azure.services.cosmosdb.cosmosdb_service import Account, CosmosDB
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
RESOURCE_GROUP,
RESOURCE_GROUP_LIST,
set_mocked_azure_provider,
)
@@ -133,3 +135,114 @@ class Test_CosmosDB_Service_None_Handling:
== "Microsoft.Network/privateEndpoints"
)
assert account.disable_local_auth is True
class Test_CosmosDB_get_accounts:
def test_get_accounts_no_resource_groups(self):
mock_client = MagicMock()
mock_client.database_accounts.list.return_value = []
with patch(
"prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts",
return_value={},
):
cosmosdb = CosmosDB(set_mocked_azure_provider())
cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cosmosdb.resource_groups = None
result = cosmosdb._get_accounts()
mock_client.database_accounts.list.assert_called_once()
mock_client.database_accounts.list_by_resource_group.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
def test_get_accounts_with_resource_group(self):
mock_account = MagicMock()
mock_account.id = "account-id"
mock_account.name = "my-cosmos"
mock_account.kind = "GlobalDocumentDB"
mock_account.location = "eastus"
mock_account.type = "Microsoft.DocumentDB/databaseAccounts"
mock_account.tags = {}
mock_account.is_virtual_network_filter_enabled = False
mock_account.private_endpoint_connections = []
mock_account.disable_local_auth = False
mock_client = MagicMock()
mock_client.database_accounts.list_by_resource_group.return_value = [
mock_account
]
with patch(
"prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts",
return_value={},
):
cosmosdb = CosmosDB(set_mocked_azure_provider())
cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cosmosdb.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]}
result = cosmosdb._get_accounts()
mock_client.database_accounts.list_by_resource_group.assert_called_once_with(
resource_group_name=RESOURCE_GROUP
)
mock_client.database_accounts.list.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert len(result[AZURE_SUBSCRIPTION_ID]) == 1
def test_get_accounts_empty_resource_group_for_subscription(self):
mock_client = MagicMock()
with patch(
"prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts",
return_value={},
):
cosmosdb = CosmosDB(set_mocked_azure_provider())
cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cosmosdb.resource_groups = {AZURE_SUBSCRIPTION_ID: []}
result = cosmosdb._get_accounts()
mock_client.database_accounts.list_by_resource_group.assert_not_called()
mock_client.database_accounts.list.assert_not_called()
assert result[AZURE_SUBSCRIPTION_ID] == []
def test_get_accounts_with_multiple_resource_groups(self):
mock_client = MagicMock()
mock_client.database_accounts.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts",
return_value={},
):
cosmosdb = CosmosDB(set_mocked_azure_provider())
cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cosmosdb.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST}
result = cosmosdb._get_accounts()
assert mock_client.database_accounts.list_by_resource_group.call_count == 2
assert AZURE_SUBSCRIPTION_ID in result
def test_get_accounts_with_mixed_case_resource_group(self):
mock_client = MagicMock()
mock_client.database_accounts.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.cosmosdb.cosmosdb_service.CosmosDB._get_accounts",
return_value={},
):
cosmosdb = CosmosDB(set_mocked_azure_provider())
cosmosdb.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
cosmosdb.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]}
cosmosdb._get_accounts()
mock_client.database_accounts.list_by_resource_group.assert_called_once_with(
resource_group_name="RG"
)
@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from prowler.providers.azure.services.databricks.databricks_service import (
Databricks,
@@ -7,6 +7,8 @@ from prowler.providers.azure.services.databricks.databricks_service import (
)
from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
RESOURCE_GROUP,
RESOURCE_GROUP_LIST,
set_mocked_azure_provider,
)
@@ -94,3 +96,123 @@ class Test_Databricks_Service_No_Encryption:
assert workspace.location == "eastus"
assert workspace.custom_managed_vnet_id == "test-vnet-id"
assert workspace.managed_disk_encryption is None
class Test_Databricks_get_workspaces:
def test_get_workspaces_no_resource_groups(self):
mock_workspace = MagicMock()
mock_workspace.id = "ws-id-1"
mock_workspace.name = "my-workspace"
mock_workspace.location = "eastus"
mock_workspace.parameters = None
mock_workspace.encryption = None
mock_workspace.public_network_access = None
mock_client = MagicMock()
mock_client.workspaces = MagicMock()
mock_client.workspaces.list_by_subscription.return_value = [mock_workspace]
with patch(
"prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces",
return_value={},
):
databricks = Databricks(set_mocked_azure_provider())
databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
databricks.resource_groups = None
result = databricks._get_workspaces()
mock_client.workspaces.list_by_subscription.assert_called_once()
mock_client.workspaces.list_by_resource_group.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert "ws-id-1" in result[AZURE_SUBSCRIPTION_ID]
def test_get_workspaces_with_resource_group(self):
mock_workspace = MagicMock()
mock_workspace.id = "ws-id-1"
mock_workspace.name = "my-workspace"
mock_workspace.location = "eastus"
mock_workspace.parameters = None
mock_workspace.encryption = None
mock_workspace.public_network_access = None
mock_client = MagicMock()
mock_client.workspaces = MagicMock()
mock_client.workspaces.list_by_resource_group.return_value = [mock_workspace]
with patch(
"prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces",
return_value={},
):
databricks = Databricks(set_mocked_azure_provider())
databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
databricks.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]}
result = databricks._get_workspaces()
mock_client.workspaces.list_by_resource_group.assert_called_once_with(
resource_group_name=RESOURCE_GROUP
)
mock_client.workspaces.list_by_subscription.assert_not_called()
assert AZURE_SUBSCRIPTION_ID in result
assert "ws-id-1" in result[AZURE_SUBSCRIPTION_ID]
def test_get_workspaces_empty_resource_group_for_subscription(self):
mock_client = MagicMock()
mock_client.workspaces = MagicMock()
with patch(
"prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces",
return_value={},
):
databricks = Databricks(set_mocked_azure_provider())
databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
databricks.resource_groups = {AZURE_SUBSCRIPTION_ID: []}
result = databricks._get_workspaces()
mock_client.workspaces.list_by_resource_group.assert_not_called()
mock_client.workspaces.list_by_subscription.assert_not_called()
assert result[AZURE_SUBSCRIPTION_ID] == {}
def test_get_workspaces_with_multiple_resource_groups(self):
mock_client = MagicMock()
mock_client.workspaces = MagicMock()
mock_client.workspaces.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces",
return_value={},
):
databricks = Databricks(set_mocked_azure_provider())
databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
databricks.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST}
result = databricks._get_workspaces()
assert mock_client.workspaces.list_by_resource_group.call_count == 2
assert AZURE_SUBSCRIPTION_ID in result
def test_get_workspaces_with_mixed_case_resource_group(self):
mock_client = MagicMock()
mock_client.workspaces = MagicMock()
mock_client.workspaces.list_by_resource_group.return_value = []
with patch(
"prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces",
return_value={},
):
databricks = Databricks(set_mocked_azure_provider())
databricks.clients = {AZURE_SUBSCRIPTION_ID: mock_client}
databricks.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]}
databricks._get_workspaces()
mock_client.workspaces.list_by_resource_group.assert_called_once_with(
resource_group_name="RG"
)
@@ -16,6 +16,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_additional_email_configured_with_a_security_contact:
def test_defender_no_subscriptions(self):
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {}
@@ -40,6 +41,7 @@ class Test_defender_additional_email_configured_with_a_security_contact:
def test_defender_no_additional_emails(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {
AZURE_SUBSCRIPTION_ID: {
@@ -87,6 +89,7 @@ class Test_defender_additional_email_configured_with_a_security_contact:
def test_defender_additional_email_configured(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_assessments_vm_endpoint_protection_installed:
def test_defender_no_subscriptions(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {}
@@ -36,6 +37,7 @@ class Test_defender_assessments_vm_endpoint_protection_installed:
def test_defender_subscriptions_with_no_assessments(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {AZURE_SUBSCRIPTION_ID: {}}
@@ -59,6 +61,7 @@ class Test_defender_assessments_vm_endpoint_protection_installed:
def test_defender_subscriptions_with_healthy_assessments(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
resource_id = str(uuid4())
defender_client.assessments = {
@@ -98,6 +101,7 @@ class Test_defender_assessments_vm_endpoint_protection_installed:
def test_defender_subscriptions_with_unhealthy_assessments(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
resource_id = str(uuid4())
defender_client.assessments = {
@@ -16,6 +16,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_attack_path_notifications_properly_configured:
def test_no_subscriptions(self):
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {}
defender_client.audit_config = {}
@@ -41,6 +42,7 @@ class Test_defender_attack_path_notifications_properly_configured:
resource_id = str(uuid4())
contact_name = "default"
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {
AZURE_SUBSCRIPTION_ID: {
@@ -89,6 +91,7 @@ class Test_defender_attack_path_notifications_properly_configured:
resource_id = str(uuid4())
contact_name = "default"
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {
AZURE_SUBSCRIPTION_ID: {
@@ -139,6 +142,7 @@ class Test_defender_attack_path_notifications_properly_configured:
resource_id = str(uuid4())
contact_name = "default"
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {
AZURE_SUBSCRIPTION_ID: {
@@ -189,6 +193,7 @@ class Test_defender_attack_path_notifications_properly_configured:
resource_id = str(uuid4())
contact_name = "default"
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {
AZURE_SUBSCRIPTION_ID: {
@@ -237,6 +242,7 @@ class Test_defender_attack_path_notifications_properly_configured:
resource_id = str(uuid4())
contact_name = "default"
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {
AZURE_SUBSCRIPTION_ID: {
@@ -285,6 +291,7 @@ class Test_defender_attack_path_notifications_properly_configured:
resource_id = str(uuid4())
contact_name = "default"
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {
AZURE_SUBSCRIPTION_ID: {
@@ -333,6 +340,7 @@ class Test_defender_attack_path_notifications_properly_configured:
resource_id = str(uuid4())
contact_name = "default"
defender_client = mock.MagicMock()
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.security_contact_configurations = {
AZURE_SUBSCRIPTION_ID: {
@@ -15,6 +15,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_auto_provisioning_log_analytics_agent_vms_on:
def test_defender_no_app_services(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.auto_provisioning_settings = {}
@@ -39,6 +40,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on:
def test_defender_auto_provisioning_log_analytics_off(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.auto_provisioning_settings = {
AZURE_SUBSCRIPTION_ID: {
@@ -80,6 +82,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on:
def test_defender_auto_provisioning_log_analytics_on(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.auto_provisioning_settings = {
AZURE_SUBSCRIPTION_ID: {
@@ -121,6 +124,7 @@ class Test_defender_auto_provisioning_log_analytics_agent_vms_on:
def test_defender_auto_provisioning_log_analytics_on_and_off(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.auto_provisioning_settings = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on:
def test_defender_no_app_services(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {}
@@ -37,6 +38,7 @@ class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on:
def test_defender_machines_no_vulnerability_assessment_solution(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {
AZURE_SUBSCRIPTION_ID: {
@@ -77,6 +79,7 @@ class Test_defender_auto_provisioning_vulnerabilty_assessments_machines_on:
def test_defender_machines_vulnerability_assessment_solution(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_container_images_resolved_vulnerabilities:
def test_defender_no_subscriptions(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {}
@@ -36,6 +37,7 @@ class Test_defender_container_images_resolved_vulnerabilities:
def test_defender_subscription_empty(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {AZURE_SUBSCRIPTION_ID: {}}
@@ -59,6 +61,7 @@ class Test_defender_container_images_resolved_vulnerabilities:
def test_defender_subscription_no_assesment(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {
AZURE_SUBSCRIPTION_ID: {
@@ -90,6 +93,7 @@ class Test_defender_container_images_resolved_vulnerabilities:
def test_defender_subscription_assesment_unhealthy(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {
AZURE_SUBSCRIPTION_ID: {
@@ -139,6 +143,7 @@ class Test_defender_container_images_resolved_vulnerabilities:
def test_defender_subscription_assesment_healthy(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {
AZURE_SUBSCRIPTION_ID: {
@@ -188,6 +193,7 @@ class Test_defender_container_images_resolved_vulnerabilities:
def test_defender_subscription_assesment_not_applicable(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.assessments = {
AZURE_SUBSCRIPTION_ID: {
@@ -14,6 +14,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_container_images_scan_enabled:
def test_defender_no_subscriptions(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {}
@@ -37,6 +38,7 @@ class Test_defender_container_images_scan_enabled:
def test_defender_subscription_empty(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {AZURE_SUBSCRIPTION_ID: {}}
@@ -60,6 +62,7 @@ class Test_defender_container_images_scan_enabled:
def test_defender_subscription_no_containers(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -92,6 +95,7 @@ class Test_defender_container_images_scan_enabled:
def test_defender_subscription_containers_no_extensions(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -137,6 +141,7 @@ class Test_defender_container_images_scan_enabled:
def test_defender_subscription_containers_container_images_scan_off(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -182,6 +187,7 @@ class Test_defender_container_images_scan_enabled:
def test_defender_subscription_containers_container_images_scan_on(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_ensure_defender_for_app_services_is_on:
def test_defender_no_app_services(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {}
@@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_app_services_is_on:
def test_defender_app_services_pricing_tier_not_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_app_services_is_on:
def test_defender_app_services_pricing_tier_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_ensure_defender_for_arm_is_on:
def test_defender_no_arm(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {}
@@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_arm_is_on:
def test_defender_arm_pricing_tier_not_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_arm_is_on:
def test_defender_arm_pricing_tier_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_ensure_defender_for_azure_sql_databases_is_on:
def test_defender_no_sql_databases(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {}
@@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_azure_sql_databases_is_on:
def test_defender_sql_databases_pricing_tier_not_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_azure_sql_databases_is_on:
def test_defender_sql_databases_pricing_tier_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_ensure_defender_for_containers_is_on:
def test_defender_no_container_registries(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {}
@@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_containers_is_on:
def test_defender_container_registries_pricing_tier_not_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_containers_is_on:
def test_defender_container_registries_pricing_tier_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_ensure_defender_for_cosmosdb_is_on:
def test_defender_no_cosmosdb(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {}
@@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_cosmosdb_is_on:
def test_defender_cosmosdb_pricing_tier_not_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_cosmosdb_is_on:
def test_defender_cosmosdb_pricing_tier_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_ensure_defender_for_databases_is_on:
def test_defender_no_databases(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {}
@@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_databases_is_on:
def test_defender_databases_sql_servers(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -70,6 +72,7 @@ class Test_defender_ensure_defender_for_databases_is_on:
def test_defender_databases_sql_server_virtual_machines(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -103,6 +106,7 @@ class Test_defender_ensure_defender_for_databases_is_on:
def test_defender_databases_open_source_relation_databases(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -136,6 +140,7 @@ class Test_defender_ensure_defender_for_databases_is_on:
def test_defender_databases_cosmosdbs(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -169,6 +174,7 @@ class Test_defender_ensure_defender_for_databases_is_on:
def test_defender_databases_all_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -228,6 +234,7 @@ class Test_defender_ensure_defender_for_databases_is_on:
def test_defender_databases_cosmosdb_not_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_ensure_defender_for_dns_is_on:
def test_defender_no_dns(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {}
@@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_dns_is_on:
def test_defender_dns_pricing_tier_not_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_dns_is_on:
def test_defender_dns_pricing_tier_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import (
class Test_defender_ensure_defender_for_keyvault_is_on:
def test_defender_no_keyvaults(self):
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {}
@@ -37,6 +38,7 @@ class Test_defender_ensure_defender_for_keyvault_is_on:
def test_defender_keyvaults_pricing_tier_not_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {
@@ -78,6 +80,7 @@ class Test_defender_ensure_defender_for_keyvault_is_on:
def test_defender_keyvaults_pricing_tier_standard(self):
resource_id = str(uuid4())
defender_client = mock.MagicMock
defender_client.resource_groups = {}
defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
defender_client.pricings = {
AZURE_SUBSCRIPTION_ID: {

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