chore: merge master into PROWLER-2085 kubeconfig fix
@@ -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"
|
||||
|
||||
@@ -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: "/"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -4762,7 +4762,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.33.0"
|
||||
version = "1.34.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 477 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
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.
|
||||
|
||||

|
||||
|
||||
## 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. |
|
||||
|
||||

|
||||
|
||||
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>
|
||||
|
||||

|
||||
|
||||
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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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: {
|
||||
|
||||