diff --git a/.env b/.env index a7a90108a6..c042b96342 100644 --- a/.env +++ b/.env @@ -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" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6a24af5fce..698fe470e7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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: "/" diff --git a/.github/renovate.json b/.github/renovate.json index be910a7f4e..d75f34620d 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -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", diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 24bbd80d98..b0a52d7eae 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -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) --- diff --git a/api/pyproject.toml b/api/pyproject.toml index 5d55a6bcf4..9638d5277f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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. diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 740556329c..a7f888b453 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -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): diff --git a/api/src/backend/api/migrations/0097_attack_paths_scan_db_defaults.py b/api/src/backend/api/migrations/0097_attack_paths_scan_db_defaults.py new file mode 100644 index 0000000000..8bcb43d50c --- /dev/null +++ b/api/src/backend/api/migrations/0097_attack_paths_scan_db_defaults.py @@ -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, + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index c2beba97b4..a280708d53 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -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, ) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 767a3aaf78..4bf755a258 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.33.0 + version: 1.34.0 description: |- Prowler API specification. @@ -9,7 +9,7 @@ info: paths: /api/v1/api-keys: get: - operationId: api_keys_list + operationId: api_v1_api_keys_list description: Retrieve a list of API keys for the tenant, with filtering support. summary: List API keys parameters: @@ -141,7 +141,7 @@ paths: $ref: '#/components/schemas/PaginatedTenantApiKeyList' description: '' post: - operationId: api_keys_create + operationId: api_v1_api_keys_create description: Create a new API key for the tenant. summary: Create a new API key tags: @@ -169,7 +169,7 @@ paths: description: '' /api/v1/api-keys/{id}: get: - operationId: api_keys_retrieve + operationId: api_v1_api_keys_retrieve description: Fetch detailed information about a specific API key by its ID. summary: Retrieve API key details parameters: @@ -220,7 +220,7 @@ paths: $ref: '#/components/schemas/TenantApiKeyResponse' description: '' patch: - operationId: api_keys_partial_update + operationId: api_v1_api_keys_partial_update description: Modify certain fields of an existing API key without affecting other settings. summary: Partially update an API key @@ -257,7 +257,7 @@ paths: description: '' /api/v1/api-keys/{id}/revoke: delete: - operationId: api_keys_revoke_destroy + operationId: api_v1_api_keys_revoke_destroy description: Revoke an API key by its ID. This action is irreversible and will prevent the key from being used. summary: Revoke an API key @@ -282,7 +282,7 @@ paths: description: API key was successfully revoked /api/v1/attack-paths-scans: get: - operationId: attack_paths_scans_list + operationId: api_v1_attack_paths_scans_list description: Retrieve Attack Paths scans for the tenant with support for filtering, ordering, and pagination. summary: List Attack Paths scans @@ -352,11 +352,26 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -370,10 +385,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -397,7 +412,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -411,10 +426,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -575,7 +590,7 @@ paths: description: '' /api/v1/attack-paths-scans/{id}: get: - operationId: attack_paths_scans_retrieve + operationId: api_v1_attack_paths_scans_retrieve description: Fetch full details for a specific Attack Paths scan. summary: Retrieve Attack Paths scan details parameters: @@ -635,7 +650,7 @@ paths: description: '' /api/v1/attack-paths-scans/{id}/queries: get: - operationId: attack_paths_scans_queries_retrieve + operationId: api_v1_attack_paths_scans_queries_retrieve description: Retrieve the catalog of Attack Paths queries available for this Attack Paths scan. summary: List Attack Paths queries @@ -698,7 +713,7 @@ paths: description: No queries found for the selected provider /api/v1/attack-paths-scans/{id}/queries/custom: post: - operationId: attack_paths_scans_queries_custom_create + operationId: api_v1_attack_paths_scans_queries_custom_create description: Execute a raw openCypher query against the Attack Paths graph. Results are filtered to the scan's provider and truncated to a maximum node count. @@ -745,7 +760,7 @@ paths: description: Query execution failed due to a database error /api/v1/attack-paths-scans/{id}/queries/run: post: - operationId: attack_paths_scans_queries_run_create + operationId: api_v1_attack_paths_scans_queries_run_create description: Execute the selected Attack Paths query against the Attack Paths graph and return the resulting subgraph. summary: Execute an Attack Paths query @@ -792,7 +807,7 @@ paths: description: Attack Paths query execution failed due to a database error /api/v1/attack-paths-scans/{id}/schema: get: - operationId: attack_paths_scans_schema_retrieve + operationId: api_v1_attack_paths_scans_schema_retrieve description: Return the cartography provider, version, and links to the schema documentation for the cloud provider associated with this Attack Paths scan. summary: Retrieve cartography schema metadata @@ -839,9 +854,11 @@ paths: description: Unable to retrieve cartography schema due to a database error /api/v1/compliance-overviews: get: - operationId: compliance_overviews_list - description: Retrieve an overview of all the compliance in a given scan. - summary: List compliance overviews for a scan + operationId: api_v1_compliance_overviews_list + description: Retrieve compliance overview data for a scan. When provider filters + are provided, the endpoint uses the latest completed scan for each matching + provider. + summary: List compliance overviews parameters: - in: query name: fields[compliance-overviews] @@ -900,6 +917,120 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form - in: query name: filter[region] schema: @@ -922,8 +1053,7 @@ paths: schema: type: string format: uuid - description: Related scan ID. - required: true + description: Related scan ID. Required unless a provider filter is provided. - name: filter[search] required: false in: query @@ -998,7 +1128,7 @@ paths: description: Compliance overviews generation task failed /api/v1/compliance-overviews/attributes: get: - operationId: compliance_overviews_attributes_retrieve + operationId: api_v1_compliance_overviews_attributes_retrieve description: Retrieve detailed attribute information for all requirements in a specific compliance framework along with the associated check IDs for each requirement. @@ -1028,6 +1158,14 @@ paths: type: string description: Compliance framework ID to get attributes for. required: true + - in: query + name: filter[scan_id] + schema: + type: string + format: uuid + description: Scan ID used to resolve the provider for multi-provider universal + frameworks (e.g. CSA CCM), so the returned check IDs match the scan's provider. + When omitted, the first provider that declares the framework is used. tags: - Compliance Overview security: @@ -1041,9 +1179,10 @@ paths: description: Compliance attributes obtained successfully /api/v1/compliance-overviews/metadata: get: - operationId: compliance_overviews_metadata_retrieve - description: Fetch unique metadata values from a set of compliance overviews. - This is useful for dynamic filtering. + operationId: api_v1_compliance_overviews_metadata_retrieve + description: Fetch unique metadata values from compliance overviews. This is + useful for dynamic filtering. When provider filters are provided, metadata + is computed from the latest completed scan for each matching provider. summary: Retrieve metadata values from compliance overviews parameters: - in: query @@ -1057,13 +1196,209 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[compliance_id] + schema: + type: string + - in: query + name: filter[compliance_id__icontains] + schema: + type: string + - in: query + name: filter[framework] + schema: + type: string + - in: query + name: filter[framework__icontains] + schema: + type: string + - in: query + name: filter[framework__iexact] + schema: + type: string + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[scan_id] schema: type: string format: uuid - description: Related scan ID. - required: true + description: Related scan ID. Required unless a provider filter is provided. + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[version] + schema: + type: string + - in: query + name: filter[version__icontains] + schema: + type: string + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - compliance_id + - -compliance_id + explode: false tags: - Compliance Overview security: @@ -1100,11 +1435,12 @@ paths: description: Compliance overviews generation task failed /api/v1/compliance-overviews/requirements: get: - operationId: compliance_overviews_requirements_retrieve - description: Retrieve a detailed overview of compliance requirements in a given - scan, grouped by compliance framework. This endpoint provides requirement-level - details and aggregates status across regions. - summary: List compliance requirements overview for a scan + operationId: api_v1_compliance_overviews_requirements_retrieve + description: Retrieve a detailed overview of compliance requirements, grouped + by compliance framework. This endpoint provides requirement-level details + and aggregates status across regions. When provider filters are provided, + the endpoint uses the latest completed scan for each matching provider. + summary: List compliance requirements overview parameters: - in: query name: fields[compliance-requirements-details] @@ -1163,6 +1499,120 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + x-spec-enum-id: 203afc16daac9b64 + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form - in: query name: filter[region] schema: @@ -1185,8 +1635,7 @@ paths: schema: type: string format: uuid - description: Related scan ID. - required: true + description: Related scan ID. Required unless a provider filter is provided. - name: filter[search] required: false in: query @@ -1249,7 +1698,7 @@ paths: description: Compliance overviews generation task failed /api/v1/finding-groups: get: - operationId: finding_groups_list + operationId: api_v1_finding_groups_list description: "\n Retrieve aggregated findings grouped by check_id.\n\n\ \ Each group shows:\n - Aggregated status (FAIL if any non-muted\ \ failure)\n - Maximum severity across all findings\n - Resource\ @@ -1422,6 +1871,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -1454,10 +1918,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -1494,10 +1958,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -1799,7 +2263,7 @@ paths: description: '' /api/v1/finding-groups/{id}/resources: get: - operationId: finding_groups_resources_retrieve + operationId: api_v1_finding_groups_resources_retrieve description: "\n Retrieve resources affected by a specific check (finding\ \ group).\n\n Returns individual resources with their current status,\ \ severity,\n and timing information including how long they have been\ @@ -1970,6 +2434,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -2002,10 +2481,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2042,10 +2521,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -2342,12 +2821,12 @@ paths: description: '' /api/v1/finding-groups/latest: get: - operationId: finding_groups_latest_retrieve + operationId: api_v1_finding_groups_latest_retrieve description: "\n Retrieve the latest available state for each finding\ \ group (check_id).\n\n This endpoint returns finding groups without\ \ requiring date filters,\n automatically using the latest available\ - \ data per check_id.\n All other filters (provider_id, provider_type,\ - \ check_id) are still supported.\n " + \ data per check_id.\n Provider, provider group, check, and computed\ + \ filters are still supported.\n " summary: List latest finding groups parameters: - in: query @@ -2394,6 +2873,471 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[category] + schema: + type: string + - in: query + name: filter[category__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_id] + schema: + type: string + - in: query + name: filter[check_id__icontains] + schema: + type: string + - in: query + name: filter[check_id__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[check_title__icontains] + schema: + type: string + - in: query + name: filter[delta] + schema: + type: string + enum: + - changed + - new + description: |- + * `new` - New + * `changed` - Changed + - in: query + name: filter[impact] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[muted] + schema: + type: boolean + description: If this filter is not provided, muted and non-muted findings + will be returned. + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + - in: query + name: filter[provider_id__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - alibabacloud + - aws + - azure + - cloudflare + - gcp + - github + - googleworkspace + - iac + - image + - kubernetes + - m365 + - mongodbatlas + - okta + - openstack + - oraclecloud + - vercel + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + * `github` - GitHub + * `mongodbatlas` - MongoDB Atlas + * `iac` - IaC + * `oraclecloud` - Oracle Cloud Infrastructure + * `alibabacloud` - Alibaba Cloud + * `cloudflare` - Cloudflare + * `openstack` - OpenStack + * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + * `okta` - Okta + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_groups] + schema: + type: string + - in: query + name: filter[resource_groups__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_name] + schema: + type: string + - in: query + name: filter[resource_name__icontains] + schema: + type: string + - in: query + name: filter[resource_name__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_type] + schema: + type: string + - in: query + name: filter[resource_type__icontains] + schema: + type: string + - in: query + name: filter[resource_type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resource_uid] + schema: + type: string + - in: query + name: filter[resource_uid__icontains] + schema: + type: string + - in: query + name: filter[resource_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[resources] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[severity] + schema: + type: string + enum: + - critical + - high + - informational + - low + - medium + description: |- + * `critical` - Critical + * `high` - High + * `medium` - Medium + * `low` - Low + * `informational` - Informational + - in: query + name: filter[status] + schema: + type: string + enum: + - FAIL + - MANUAL + - PASS + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - id + - -id + - check_id + - -check_id + - check_title + - -check_title + - check_description + - -check_description + - severity + - -severity + - status + - -status + - muted + - -muted + - impacted_providers + - -impacted_providers + - resources_fail + - -resources_fail + - resources_total + - -resources_total + - pass_count + - -pass_count + - fail_count + - -fail_count + - manual_count + - -manual_count + - pass_muted_count + - -pass_muted_count + - fail_muted_count + - -fail_muted_count + - manual_muted_count + - -manual_muted_count + - muted_count + - -muted_count + - new_count + - -new_count + - changed_count + - -changed_count + - new_fail_count + - -new_fail_count + - new_fail_muted_count + - -new_fail_muted_count + - new_pass_count + - -new_pass_count + - new_pass_muted_count + - -new_pass_muted_count + - new_manual_count + - -new_manual_count + - new_manual_muted_count + - -new_manual_muted_count + - changed_fail_count + - -changed_fail_count + - changed_fail_muted_count + - -changed_fail_muted_count + - changed_pass_count + - -changed_pass_count + - changed_pass_muted_count + - -changed_pass_muted_count + - changed_manual_count + - -changed_manual_count + - changed_manual_muted_count + - -changed_manual_muted_count + - first_seen_at + - -first_seen_at + - last_seen_at + - -last_seen_at + - failing_since + - -failing_since + explode: false tags: - Finding Groups security: @@ -2407,7 +3351,7 @@ paths: description: '' /api/v1/finding-groups/latest/{check_id}/resources: get: - operationId: finding_groups_latest_resources_retrieve + operationId: api_v1_finding_groups_latest_resources_retrieve description: "\n Retrieve resources affected by a specific check (finding\ \ group) from the\n latest completed scan for each provider.\n\n \ \ Returns individual resources with their current status, severity,\n\ @@ -2561,6 +3505,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -2593,10 +3552,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2633,10 +3592,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -2926,7 +3885,7 @@ paths: description: '' /api/v1/findings: get: - operationId: findings_list + operationId: api_v1_findings_list description: Retrieve a list of all findings with options for filtering by various criteria. summary: List all findings @@ -3068,13 +4027,11 @@ paths: name: filter[inserted_at__gte] schema: type: string - format: date description: Maximum date range is 7 days. - in: query name: filter[inserted_at__lte] schema: type: string - format: date description: Maximum date range is 7 days. - in: query name: filter[muted] @@ -3114,6 +4071,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -3133,7 +4105,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -3147,10 +4119,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3174,7 +4146,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -3188,10 +4160,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -3422,6 +4394,10 @@ paths: style: form - in: query name: filter[updated_at] + schema: + type: string + - in: query + name: filter[updated_at__date] schema: type: string format: date @@ -3429,12 +4405,12 @@ paths: name: filter[updated_at__gte] schema: type: string - format: date-time + description: Maximum date range is 7 days. - in: query name: filter[updated_at__lte] schema: type: string - format: date-time + description: Maximum date range is 7 days. - in: query name: include schema: @@ -3492,7 +4468,7 @@ paths: description: '' /api/v1/findings/{id}: get: - operationId: findings_retrieve + operationId: api_v1_findings_retrieve description: Fetch detailed information about a specific finding by its ID. summary: Retrieve data from a specific finding parameters: @@ -3556,7 +4532,7 @@ paths: description: '' /api/v1/findings/findings_services_regions: get: - operationId: findings_findings_services_regions_retrieve + operationId: api_v1_findings_findings_services_regions_retrieve description: Fetch services and regions affected in findings. summary: Retrieve the services and regions that are impacted by findings parameters: @@ -3668,7 +4644,6 @@ paths: name: filter[inserted_at] schema: type: string - format: date - in: query name: filter[inserted_at__date] schema: @@ -3678,13 +4653,11 @@ paths: name: filter[inserted_at__gte] schema: type: string - format: date description: Maximum date range is 7 days. - in: query name: filter[inserted_at__lte] schema: type: string - format: date description: Maximum date range is 7 days. - in: query name: filter[muted] @@ -3724,6 +4697,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -3743,7 +4731,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -3757,10 +4745,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3784,7 +4772,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -3798,10 +4786,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -4032,6 +5020,10 @@ paths: style: form - in: query name: filter[updated_at] + schema: + type: string + - in: query + name: filter[updated_at__date] schema: type: string format: date @@ -4039,12 +5031,12 @@ paths: name: filter[updated_at__gte] schema: type: string - format: date-time + description: Maximum date range is 7 days. - in: query name: filter[updated_at__lte] schema: type: string - format: date-time + description: Maximum date range is 7 days. - name: sort required: false in: query @@ -4079,7 +5071,7 @@ paths: description: '' /api/v1/findings/latest: get: - operationId: findings_latest_retrieve + operationId: api_v1_findings_latest_retrieve description: Retrieve a list of the latest findings from the latest scans for each provider with options for filtering by various criteria. summary: List the latest findings @@ -4242,6 +5234,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -4261,7 +5268,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -4275,10 +5282,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4302,7 +5309,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -4316,10 +5323,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -4583,7 +5590,7 @@ paths: description: '' /api/v1/findings/metadata: get: - operationId: findings_metadata_retrieve + operationId: api_v1_findings_metadata_retrieve description: Fetch unique metadata values from a set of findings. This is useful for dynamic filtering. summary: Retrieve metadata values from findings @@ -4758,6 +5765,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -4777,7 +5799,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -4791,10 +5813,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4818,7 +5840,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -4832,10 +5854,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -5112,7 +6134,7 @@ paths: description: '' /api/v1/findings/metadata/latest: get: - operationId: findings_metadata_latest_retrieve + operationId: api_v1_findings_metadata_latest_retrieve description: Fetch unique metadata values from a set of findings from the latest scans for each provider. This is useful for dynamic filtering. summary: Retrieve metadata values from the latest findings @@ -5262,6 +6284,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -5281,7 +6318,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -5295,10 +6332,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -5322,7 +6359,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -5336,10 +6373,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -5591,7 +6628,7 @@ paths: description: '' /api/v1/integrations: get: - operationId: integrations_list + operationId: api_v1_integrations_list description: Retrieve a list of all configured integrations with options for filtering by various criteria. summary: List all integrations @@ -5742,7 +6779,7 @@ paths: $ref: '#/components/schemas/PaginatedIntegrationList' description: '' post: - operationId: integrations_create + operationId: api_v1_integrations_create description: Register a new integration with the system, providing necessary configuration details. summary: Create a new integration @@ -5771,7 +6808,7 @@ paths: description: '' /api/v1/integrations/{integration_pk}/jira/dispatches: post: - operationId: integrations_jira_dispatches_create + operationId: api_v1_integrations_jira_dispatches_create description: |- Send a set of filtered findings to the given integration. At least one finding filter must be provided. @@ -5844,7 +6881,7 @@ paths: description: '' /api/v1/integrations/{integration_pk}/jira/issue_types: get: - operationId: integrations_jira_issue_types_retrieve + operationId: api_v1_integrations_jira_issue_types_retrieve description: Fetch the available issue types from Jira for a given project key and update the integration configuration. summary: Get available issue types for a Jira project @@ -5885,7 +6922,7 @@ paths: description: '' /api/v1/integrations/{id}: get: - operationId: integrations_retrieve + operationId: api_v1_integrations_retrieve description: Fetch detailed information about a specific integration by its ID. summary: Retrieve integration details @@ -5939,7 +6976,7 @@ paths: $ref: '#/components/schemas/IntegrationResponse' description: '' patch: - operationId: integrations_partial_update + operationId: api_v1_integrations_partial_update description: Modify certain fields of an existing integration without affecting other settings. summary: Partially update an integration @@ -5975,7 +7012,7 @@ paths: $ref: '#/components/schemas/IntegrationUpdateResponse' description: '' delete: - operationId: integrations_destroy + operationId: api_v1_integrations_destroy description: Remove an integration from the system by its ID. summary: Delete an integration parameters: @@ -5995,7 +7032,7 @@ paths: description: No response body /api/v1/integrations/{id}/connection: post: - operationId: integrations_connection_create + operationId: api_v1_integrations_connection_create description: Try to verify integration connection summary: Check integration connection parameters: @@ -6034,7 +7071,7 @@ paths: description: '' /api/v1/invitations/accept: post: - operationId: invitations_accept_create + operationId: api_v1_invitations_accept_create description: Accept an invitation to an existing tenant. This invitation cannot be expired and the emails must match. summary: Accept an invitation @@ -6063,7 +7100,7 @@ paths: description: '' /api/v1/lighthouse-configurations: get: - operationId: lighthouse_configurations_list + operationId: api_v1_lighthouse_configurations_list description: Retrieve a list of all Lighthouse AI configurations. summary: List all Lighthouse AI configurations parameters: @@ -6136,7 +7173,7 @@ paths: $ref: '#/components/schemas/PaginatedLighthouseConfigList' description: '' post: - operationId: lighthouse_configurations_create + operationId: api_v1_lighthouse_configurations_create description: Create a new Lighthouse AI configuration with the specified details. summary: Create a new Lighthouse AI configuration tags: @@ -6165,7 +7202,7 @@ paths: description: '' /api/v1/lighthouse-configurations/{id}: patch: - operationId: lighthouse_configurations_partial_update + operationId: api_v1_lighthouse_configurations_partial_update description: Update certain fields of an existing Lighthouse AI configuration. summary: Partially update a Lighthouse AI configuration parameters: @@ -6199,7 +7236,7 @@ paths: $ref: '#/components/schemas/LighthouseConfigUpdateResponse' description: '' delete: - operationId: lighthouse_configurations_destroy + operationId: api_v1_lighthouse_configurations_destroy description: Remove a Lighthouse AI configuration by its ID. summary: Delete a Lighthouse AI configuration parameters: @@ -6218,7 +7255,7 @@ paths: description: No response body /api/v1/lighthouse-configurations/{id}/connection: post: - operationId: lighthouse_configurations_connection_create + operationId: api_v1_lighthouse_configurations_connection_create description: Verify the connection to the OpenAI API for a specific Lighthouse AI configuration. summary: Check the connection to the OpenAI API @@ -6257,7 +7294,7 @@ paths: description: '' /api/v1/lighthouse/configuration: get: - operationId: lighthouse_configuration_list + operationId: api_v1_lighthouse_configuration_list description: Retrieve current tenant-level Lighthouse AI settings. Returns a single configuration object. summary: Get Lighthouse AI Tenant config @@ -6332,7 +7369,7 @@ paths: $ref: '#/components/schemas/PaginatedLighthouseTenantConfigList' description: '' patch: - operationId: lighthouse_configuration_partial_update + operationId: api_v1_lighthouse_configuration_partial_update description: Update tenant-level settings. Validates that the default provider is configured and active and that default model IDs exist for the chosen providers. Auto-creates configuration if it doesn't exist. @@ -6362,7 +7399,7 @@ paths: description: '' /api/v1/lighthouse/models: get: - operationId: lighthouse_models_list + operationId: api_v1_lighthouse_models_list description: List available LLM models per configured provider for the current tenant. summary: List all LLM models @@ -6492,7 +7529,7 @@ paths: description: '' /api/v1/lighthouse/models/{id}: get: - operationId: lighthouse_models_retrieve + operationId: api_v1_lighthouse_models_retrieve description: Get details for a specific LLM model. summary: Retrieve LLM model details parameters: @@ -6533,7 +7570,7 @@ paths: description: '' /api/v1/lighthouse/providers: get: - operationId: lighthouse_providers_list + operationId: api_v1_lighthouse_providers_list description: Retrieve all LLM provider configurations for the current tenant summary: List all LLM provider configurations parameters: @@ -6648,7 +7685,7 @@ paths: $ref: '#/components/schemas/PaginatedLighthouseProviderConfigList' description: '' post: - operationId: lighthouse_providers_create + operationId: api_v1_lighthouse_providers_create description: Create a per-tenant configuration for an LLM provider. Only one configuration per provider type is allowed per tenant. summary: Create LLM provider configuration @@ -6677,7 +7714,7 @@ paths: description: '' /api/v1/lighthouse/providers/{id}: get: - operationId: lighthouse_providers_retrieve + operationId: api_v1_lighthouse_providers_retrieve description: Get details for a specific provider configuration in the current tenant. summary: Retrieve LLM provider configuration @@ -6718,7 +7755,7 @@ paths: $ref: '#/components/schemas/LighthouseProviderConfigResponse' description: '' patch: - operationId: lighthouse_providers_partial_update + operationId: api_v1_lighthouse_providers_partial_update description: Partially update a provider configuration (e.g., base_url, is_active). summary: Update LLM provider configuration parameters: @@ -6753,7 +7790,7 @@ paths: $ref: '#/components/schemas/LighthouseProviderConfigUpdateResponse' description: '' delete: - operationId: lighthouse_providers_destroy + operationId: api_v1_lighthouse_providers_destroy description: Delete a provider configuration. Any tenant defaults that reference this provider are cleared during deletion. summary: Delete LLM provider configuration @@ -6774,7 +7811,7 @@ paths: description: No response body /api/v1/lighthouse/providers/{id}/connection: post: - operationId: lighthouse_providers_connection_create + operationId: api_v1_lighthouse_providers_connection_create description: Validate provider credentials asynchronously and toggle is_active. summary: Check LLM provider connection parameters: @@ -6813,7 +7850,7 @@ paths: description: '' /api/v1/lighthouse/providers/{id}/refresh-models: post: - operationId: lighthouse_providers_refresh_models_create + operationId: api_v1_lighthouse_providers_refresh_models_create description: Fetch available models for this provider configuration and upsert into catalog. Supports OpenAI, OpenAI-compatible, and AWS Bedrock providers. summary: Refresh LLM models catalog @@ -6853,7 +7890,7 @@ paths: description: '' /api/v1/mute-rules: get: - operationId: mute_rules_list + operationId: api_v1_mute_rules_list description: Retrieve a list of all mute rules with filtering options. summary: List all mute rules parameters: @@ -6999,7 +8036,7 @@ paths: $ref: '#/components/schemas/PaginatedMuteRuleList' description: '' post: - operationId: mute_rules_create + operationId: api_v1_mute_rules_create description: Create a new mute rule by providing finding IDs, name, and reason. The rule will immediately mute the selected findings and launch a background task to mute all historical findings with matching UIDs. @@ -7029,7 +8066,7 @@ paths: description: '' /api/v1/mute-rules/{id}: get: - operationId: mute_rules_retrieve + operationId: api_v1_mute_rules_retrieve description: Fetch detailed information about a specific mute rule by ID. summary: Retrieve a mute rule parameters: @@ -7080,7 +8117,7 @@ paths: $ref: '#/components/schemas/MuteRuleResponse' description: '' patch: - operationId: mute_rules_partial_update + operationId: api_v1_mute_rules_partial_update description: Update certain fields of an existing mute rule (e.g., name, reason, enabled). summary: Partially update a mute rule @@ -7116,7 +8153,7 @@ paths: $ref: '#/components/schemas/SerializerMetaclassResponse' description: '' delete: - operationId: mute_rules_destroy + operationId: api_v1_mute_rules_destroy description: 'Remove a mute rule from the system. Note: Previously muted findings remain muted.' summary: Delete a mute rule @@ -7137,7 +8174,7 @@ paths: description: No response body /api/v1/overviews/attack-surfaces: get: - operationId: overviews_attack_surfaces_list + operationId: api_v1_overviews_attack_surfaces_list description: Retrieve aggregated attack surface metrics from latest completed scans per provider. summary: Get attack surface overview @@ -7156,6 +8193,21 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7175,7 +8227,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7189,10 +8241,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7216,7 +8268,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7230,10 +8282,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -7304,7 +8356,7 @@ paths: description: '' /api/v1/overviews/categories: get: - operationId: overviews_categories_list + operationId: api_v1_overviews_categories_list description: 'Retrieve aggregated category metrics from latest completed scans per provider. Returns one row per category with total, failed, and new failed findings counts, plus a severity breakdown showing failed findings per severity @@ -7339,6 +8391,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7358,7 +8425,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7372,10 +8439,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7399,7 +8466,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7413,10 +8480,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -7489,7 +8556,7 @@ paths: description: '' /api/v1/overviews/compliance-watchlist: get: - operationId: overviews_compliance_watchlist_list + operationId: api_v1_overviews_compliance_watchlist_list description: 'Retrieve compliance metrics with FAIL-dominant aggregation. Without filters: uses pre-aggregated TenantComplianceSummary. With provider filters: queries ProviderComplianceScore with FAIL-dominant logic where any FAIL in @@ -7512,6 +8579,21 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7544,10 +8626,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7584,10 +8666,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -7662,7 +8744,7 @@ paths: description: '' /api/v1/overviews/findings: get: - operationId: overviews_findings_retrieve + operationId: api_v1_overviews_findings_retrieve description: Fetch aggregated findings data across all providers, grouped by various metrics such as passed, failed, muted, and total findings. This endpoint calculates summary statistics based on the latest scans for each provider @@ -7714,6 +8796,21 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7733,7 +8830,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7747,10 +8844,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7774,7 +8871,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7788,10 +8885,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -7887,7 +8984,7 @@ paths: description: '' /api/v1/overviews/findings_severity: get: - operationId: overviews_findings_severity_retrieve + operationId: api_v1_overviews_findings_severity_retrieve description: Retrieve an aggregated summary of findings grouped by severity levels, such as low, medium, high, and critical. The response includes the total count of findings for each severity, considering only the latest scans @@ -7931,6 +9028,21 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -7950,7 +9062,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -7964,10 +9076,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7991,7 +9103,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8005,10 +9117,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8107,7 +9219,7 @@ paths: description: '' /api/v1/overviews/findings_severity/timeseries: get: - operationId: overviews_findings_severity_timeseries_retrieve + operationId: api_v1_overviews_findings_severity_timeseries_retrieve description: Retrieve daily aggregated findings data grouped by severity levels over a date range. Returns one data point per day with counts of failed findings by severity (critical, high, medium, low, informational) and muted findings. @@ -8143,6 +9255,21 @@ paths: schema: type: string format: date + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -8175,10 +9302,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8215,10 +9342,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8285,7 +9412,7 @@ paths: description: '' /api/v1/overviews/providers: get: - operationId: overviews_providers_retrieve + operationId: api_v1_overviews_providers_retrieve description: Retrieve an aggregated overview of findings and resources grouped by providers. The response includes the count of passed, failed, and manual findings, along with the total number of resources managed by each provider. @@ -8319,7 +9446,7 @@ paths: description: '' /api/v1/overviews/providers/count: get: - operationId: overviews_providers_count_retrieve + operationId: api_v1_overviews_providers_count_retrieve description: Retrieve the number of providers grouped by provider type. This endpoint counts every provider in the tenant, including those without completed scans. @@ -8350,7 +9477,7 @@ paths: description: '' /api/v1/overviews/regions: get: - operationId: overviews_regions_retrieve + operationId: api_v1_overviews_regions_retrieve description: Retrieve an aggregated summary of findings grouped by region. The response includes the total, passed, failed, and muted findings for each region based on the latest completed scans per provider. Standard overview filters @@ -8394,6 +9521,21 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -8413,7 +9555,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8427,10 +9569,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8454,7 +9596,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8468,10 +9610,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8553,7 +9695,7 @@ paths: description: '' /api/v1/overviews/resource-groups: get: - operationId: overviews_resource_groups_list + operationId: api_v1_overviews_resource_groups_list description: Retrieve aggregated resource group metrics from latest completed scans per provider. Returns one row per resource group with total, failed, and new failed findings counts, plus a severity breakdown showing failed findings @@ -8576,6 +9718,21 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -8595,7 +9752,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8609,10 +9766,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8636,7 +9793,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8650,10 +9807,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8741,7 +9898,7 @@ paths: description: '' /api/v1/overviews/services: get: - operationId: overviews_services_retrieve + operationId: api_v1_overviews_services_retrieve description: Retrieve an aggregated summary of findings grouped by service. The response includes the total count of findings for each service, as long as there are at least one finding for that service. @@ -8782,6 +9939,21 @@ paths: schema: type: string format: date-time + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -8801,7 +9973,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8815,10 +9987,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8842,7 +10014,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -8856,10 +10028,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -8937,7 +10109,7 @@ paths: description: '' /api/v1/overviews/threatscore: get: - operationId: overviews_threatscore_retrieve + operationId: api_v1_overviews_threatscore_retrieve description: Retrieve ThreatScore metrics. By default, returns the latest snapshot for each provider. Use snapshot_id to retrieve a specific historical snapshot. summary: Get ThreatScore snapshots @@ -8968,6 +10140,38 @@ paths: description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + description: Filter by provider group ID + - in: query + name: filter[provider_groups__in] + schema: + type: string + description: Filter by multiple provider group IDs (comma-separated UUIDs) + - in: query + name: filter[provider_id] + schema: + type: string + format: uuid + description: Filter by specific provider ID + - in: query + name: filter[provider_id__in] + schema: + type: string + description: Filter by multiple provider IDs (comma-separated UUIDs) + - in: query + name: filter[provider_type] + schema: + type: string + description: Filter by provider type (aws, azure, gcp, etc.) + - in: query + name: filter[provider_type__in] + schema: + type: string + description: Filter by multiple provider types (comma-separated) - in: query name: include schema: @@ -8980,27 +10184,6 @@ paths: description: include query parameter to allow the client to customize which related resources should be returned. explode: false - - in: query - name: provider_id - schema: - type: string - format: uuid - description: Filter by specific provider ID - - in: query - name: provider_id__in - schema: - type: string - description: Filter by multiple provider IDs (comma-separated UUIDs) - - in: query - name: provider_type - schema: - type: string - description: Filter by provider type (aws, azure, gcp, etc.) - - in: query - name: provider_type__in - schema: - type: string - description: Filter by multiple provider types (comma-separated) - in: query name: snapshot_id schema: @@ -9021,7 +10204,7 @@ paths: description: '' /api/v1/processors: get: - operationId: processors_list + operationId: api_v1_processors_list description: Retrieve a list of all configured processors with options for filtering by various criteria. summary: List all processors @@ -9116,7 +10299,7 @@ paths: $ref: '#/components/schemas/PaginatedProcessorList' description: '' post: - operationId: processors_create + operationId: api_v1_processors_create description: Register a new processor with the system, providing necessary configuration details. There can only be one processor of each type per tenant. summary: Create a new processor @@ -9145,7 +10328,7 @@ paths: description: '' /api/v1/processors/{id}: get: - operationId: processors_retrieve + operationId: api_v1_processors_retrieve description: Fetch detailed information about a specific processor by its ID. summary: Retrieve processor details parameters: @@ -9183,7 +10366,7 @@ paths: $ref: '#/components/schemas/ProcessorResponse' description: '' patch: - operationId: processors_partial_update + operationId: api_v1_processors_partial_update description: Modify certain fields of an existing processor without affecting other settings. summary: Partially update a processor @@ -9219,7 +10402,7 @@ paths: $ref: '#/components/schemas/ProcessorUpdateResponse' description: '' delete: - operationId: processors_destroy + operationId: api_v1_processors_destroy description: Remove a processor from the system by its ID. summary: Delete a processor parameters: @@ -9239,7 +10422,7 @@ paths: description: No response body /api/v1/provider-groups: get: - operationId: provider_groups_list + operationId: api_v1_provider_groups_list description: Retrieve a list of all provider groups with options for filtering by various criteria. summary: List all provider groups @@ -9372,7 +10555,7 @@ paths: $ref: '#/components/schemas/PaginatedProviderGroupList' description: '' post: - operationId: provider_groups_create + operationId: api_v1_provider_groups_create description: Add a new provider group to the system by providing the required provider group details. summary: Create a new provider group @@ -9401,7 +10584,7 @@ paths: description: '' /api/v1/provider-groups/{id}: get: - operationId: provider_groups_retrieve + operationId: api_v1_provider_groups_retrieve description: Fetch detailed information about a specific provider group by their ID. summary: Retrieve data from a provider group @@ -9441,7 +10624,7 @@ paths: $ref: '#/components/schemas/ProviderGroupResponse' description: '' patch: - operationId: provider_groups_partial_update + operationId: api_v1_provider_groups_partial_update description: Update certain fields of an existing provider group's information without affecting other fields. summary: Partially update a provider group @@ -9477,7 +10660,7 @@ paths: $ref: '#/components/schemas/SerializerMetaclassResponse' description: '' delete: - operationId: provider_groups_destroy + operationId: api_v1_provider_groups_destroy description: Remove a provider group from the system by their ID. summary: Delete a provider group parameters: @@ -9497,7 +10680,7 @@ paths: description: No response body /api/v1/provider-groups/{id}/relationships/providers: post: - operationId: provider_groups_relationships_providers_create + operationId: api_v1_provider_groups_relationships_providers_create description: Add a new provider_group-providers relationship to the system by providing the required provider_group-providers details. summary: Create a new provider_group-providers relationship @@ -9523,7 +10706,7 @@ paths: '400': description: Bad request (e.g., relationship already exists) patch: - operationId: provider_groups_relationships_providers_partial_update + operationId: api_v1_provider_groups_relationships_providers_partial_update description: Update the provider_group-providers relationship information without affecting other fields. summary: Partially update a provider_group-providers relationship @@ -9547,7 +10730,7 @@ paths: '204': description: Relationship updated successfully delete: - operationId: provider_groups_relationships_providers_destroy + operationId: api_v1_provider_groups_relationships_providers_destroy description: Remove the provider_group-providers relationship from the system by their ID. summary: Delete a provider_group-providers relationship @@ -9560,7 +10743,7 @@ paths: description: Relationship deleted successfully /api/v1/providers: get: - operationId: providers_list + operationId: api_v1_providers_list description: Retrieve a list of all providers with options for filtering by various criteria. summary: List all providers @@ -9648,7 +10831,7 @@ paths: name: filter[provider] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -9662,10 +10845,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9689,7 +10872,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -9703,10 +10886,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -9728,11 +10911,26 @@ paths: * `okta` - Okta explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -9746,10 +10944,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9773,7 +10971,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -9787,10 +10985,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -9910,7 +11108,7 @@ paths: $ref: '#/components/schemas/PaginatedProviderList' description: '' post: - operationId: providers_create + operationId: api_v1_providers_create description: Add a new provider to the system by providing the required provider details. summary: Create a new provider @@ -9939,7 +11137,7 @@ paths: description: '' /api/v1/providers/{id}: get: - operationId: providers_retrieve + operationId: api_v1_providers_retrieve description: Fetch detailed information about a specific provider by their ID. summary: Retrieve data from a provider parameters: @@ -9992,7 +11190,7 @@ paths: $ref: '#/components/schemas/ProviderResponse' description: '' patch: - operationId: providers_partial_update + operationId: api_v1_providers_partial_update description: Update certain fields of an existing provider's information without affecting other fields. summary: Partially update a provider @@ -10028,7 +11226,7 @@ paths: $ref: '#/components/schemas/SerializerMetaclassResponse' description: '' delete: - operationId: providers_destroy + operationId: api_v1_providers_destroy description: Remove a provider from the system by their ID. summary: Delete a provider parameters: @@ -10067,7 +11265,7 @@ paths: description: '' /api/v1/providers/{id}/connection: post: - operationId: providers_connection_create + operationId: api_v1_providers_connection_create description: Try to verify connection. For instance, Role & Credentials are set correctly summary: Check connection @@ -10107,7 +11305,7 @@ paths: description: '' /api/v1/providers/secrets: get: - operationId: providers_secrets_list + operationId: api_v1_providers_secrets_list description: Retrieve a list of all secrets with options for filtering by various criteria. summary: List all secrets @@ -10199,7 +11397,7 @@ paths: $ref: '#/components/schemas/PaginatedProviderSecretList' description: '' post: - operationId: providers_secrets_create + operationId: api_v1_providers_secrets_create description: Add a new secret to the system by providing the required secret details. summary: Create a new secret @@ -10228,7 +11426,7 @@ paths: description: '' /api/v1/providers/secrets/{id}: get: - operationId: providers_secrets_retrieve + operationId: api_v1_providers_secrets_retrieve description: Fetch detailed information about a specific secret by their ID. summary: Retrieve data from a secret parameters: @@ -10266,7 +11464,7 @@ paths: $ref: '#/components/schemas/ProviderSecretResponse' description: '' patch: - operationId: providers_secrets_partial_update + operationId: api_v1_providers_secrets_partial_update description: Update certain fields of an existing secret's information without affecting other fields. summary: Partially update a secret @@ -10301,7 +11499,7 @@ paths: $ref: '#/components/schemas/ProviderSecretUpdateResponse' description: '' delete: - operationId: providers_secrets_destroy + operationId: api_v1_providers_secrets_destroy description: Remove a secret from the system by their ID. summary: Delete a secret parameters: @@ -10320,7 +11518,7 @@ paths: description: No response body /api/v1/resources: get: - operationId: resources_list + operationId: api_v1_resources_list description: Retrieve a list of all resources with options for filtering by various criteria. Resources are objects that are discovered by Prowler. They can be anything from a single host to a whole VPC. @@ -10444,6 +11642,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -10463,7 +11676,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -10477,10 +11690,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -10504,7 +11717,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -10518,10 +11731,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -10746,7 +11959,7 @@ paths: description: '' /api/v1/resources/{id}: get: - operationId: resources_retrieve + operationId: api_v1_resources_retrieve description: Fetch detailed information about a specific resource by their ID. A Resource is an object that is discovered by Prowler. It can be anything from a single host to a whole VPC. @@ -10810,7 +12023,7 @@ paths: description: '' /api/v1/resources/{id}/events: get: - operationId: resources_events_list + operationId: api_v1_resources_events_list description: |- Retrieve events showing modification history for a resource. Returns who modified the resource and when. Currently only available for AWS resources. @@ -10891,7 +12104,7 @@ paths: description: Provider service unavailable /api/v1/resources/latest: get: - operationId: resources_latest_retrieve + operationId: api_v1_resources_latest_retrieve description: Retrieve a list of the latest resources from the latest scans for each provider with options for filtering by various criteria. summary: List the latest resources @@ -10999,6 +12212,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -11018,7 +12246,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11032,10 +12260,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11059,7 +12287,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11073,10 +12301,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -11256,7 +12484,7 @@ paths: description: '' /api/v1/resources/metadata: get: - operationId: resources_metadata_retrieve + operationId: api_v1_resources_metadata_retrieve description: Fetch unique metadata values from a set of resources. This is useful for dynamic filtering. summary: Retrieve metadata values from resources @@ -11367,6 +12595,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -11386,7 +12629,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11400,10 +12643,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11427,7 +12670,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11441,10 +12684,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -11645,7 +12888,7 @@ paths: description: '' /api/v1/resources/metadata/latest: get: - operationId: resources_metadata_latest_retrieve + operationId: api_v1_resources_metadata_latest_retrieve description: Fetch unique metadata values from a set of resources from the latest scans for each provider. This is useful for dynamic filtering. summary: Retrieve metadata values from the latest resources @@ -11741,6 +12984,21 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_id] schema: @@ -11760,7 +13018,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11774,10 +13032,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11801,7 +13059,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -11815,10 +13073,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -11986,7 +13244,7 @@ paths: description: '' /api/v1/roles: get: - operationId: roles_list + operationId: api_v1_roles_list description: Retrieve a list of all roles with options for filtering by various criteria. summary: List all roles @@ -12155,7 +13413,7 @@ paths: $ref: '#/components/schemas/PaginatedRoleList' description: '' post: - operationId: roles_create + operationId: api_v1_roles_create description: Add a new role to the system by providing the required role details. summary: Create a new role tags: @@ -12183,7 +13441,7 @@ paths: description: '' /api/v1/roles/{id}: get: - operationId: roles_retrieve + operationId: api_v1_roles_retrieve description: Fetch detailed information about a specific role by their ID. summary: Retrieve data from a role parameters: @@ -12230,7 +13488,7 @@ paths: $ref: '#/components/schemas/RoleResponse' description: '' patch: - operationId: roles_partial_update + operationId: api_v1_roles_partial_update description: Update selected fields on an existing role. When changing the `users` relationship of a role that grants MANAGE_ACCOUNT, the API blocks attempts that would leave the tenant without any MANAGE_ACCOUNT assignees and prevents @@ -12268,7 +13526,7 @@ paths: $ref: '#/components/schemas/SerializerMetaclassResponse' description: '' delete: - operationId: roles_destroy + operationId: api_v1_roles_destroy description: Delete the specified role. The API rejects deletion of the last role in the tenant that grants MANAGE_ACCOUNT. summary: Delete a role @@ -12289,7 +13547,7 @@ paths: description: No response body /api/v1/roles/{id}/relationships/provider_groups: post: - operationId: roles_relationships_provider_groups_create + operationId: api_v1_roles_relationships_provider_groups_create description: Add a new role-provider_groups relationship to the system by providing the required role-provider_groups details. summary: Create a new role-provider_groups relationship @@ -12315,7 +13573,7 @@ paths: '400': description: Bad request (e.g., relationship already exists) patch: - operationId: roles_relationships_provider_groups_partial_update + operationId: api_v1_roles_relationships_provider_groups_partial_update description: Update the role-provider_groups relationship information without affecting other fields. summary: Partially update a role-provider_groups relationship @@ -12339,7 +13597,7 @@ paths: '204': description: Relationship updated successfully delete: - operationId: roles_relationships_provider_groups_destroy + operationId: api_v1_roles_relationships_provider_groups_destroy description: Remove the role-provider_groups relationship from the system by their ID. summary: Delete a role-provider_groups relationship @@ -12352,7 +13610,7 @@ paths: description: Relationship deleted successfully /api/v1/saml-config: get: - operationId: saml_config_list + operationId: api_v1_saml_config_list description: Returns all the SAML-based SSO configurations associated with the current tenant. summary: List all SSO configurations @@ -12421,7 +13679,7 @@ paths: $ref: '#/components/schemas/PaginatedSAMLConfigurationList' description: '' post: - operationId: saml_config_create + operationId: api_v1_saml_config_create description: Creates a new SAML SSO configuration for the current tenant, including email domain and metadata XML. summary: Create the SSO configuration @@ -12450,7 +13708,7 @@ paths: description: '' /api/v1/saml-config/{id}: get: - operationId: saml_config_retrieve + operationId: api_v1_saml_config_retrieve description: Returns the details of a specific SAML configuration belonging to the current tenant. summary: Retrieve SSO configuration details @@ -12488,7 +13746,7 @@ paths: $ref: '#/components/schemas/SAMLConfigurationResponse' description: '' patch: - operationId: saml_config_partial_update + operationId: api_v1_saml_config_partial_update description: Partially updates an existing SAML SSO configuration. Supports changes to email domain and metadata XML. summary: Update the SSO configuration @@ -12524,7 +13782,7 @@ paths: $ref: '#/components/schemas/SAMLConfigurationResponse' description: '' delete: - operationId: saml_config_destroy + operationId: api_v1_saml_config_destroy description: Deletes an existing SAML SSO configuration associated with the current tenant. summary: Delete the SSO configuration @@ -12545,7 +13803,7 @@ paths: description: No response body /api/v1/scans: get: - operationId: scans_list + operationId: api_v1_scans_list description: Retrieve a list of all scans with options for filtering by various criteria. summary: List all scans @@ -12655,11 +13913,26 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[provider_groups] + schema: + type: string + format: uuid + - in: query + name: filter[provider_groups__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -12673,10 +13946,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- * `aws` - AWS * `azure` - Azure @@ -12700,7 +13973,7 @@ paths: type: array items: type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 enum: - alibabacloud - aws @@ -12714,10 +13987,10 @@ paths: - kubernetes - m365 - mongodbatlas + - okta - openstack - oraclecloud - vercel - - okta description: |- Multiple values may be separated by commas. @@ -12889,7 +14162,7 @@ paths: $ref: '#/components/schemas/PaginatedScanList' description: '' post: - operationId: scans_create + operationId: api_v1_scans_create description: Trigger a manual scan by providing the required scan details. If `scanner_args` are not provided, the system will automatically use the default settings from the associated provider. If you do provide `scanner_args`, these @@ -12937,7 +14210,7 @@ paths: description: '' /api/v1/scans/{id}: get: - operationId: scans_retrieve + operationId: api_v1_scans_retrieve description: Fetch detailed information about a specific scan by its ID. summary: Retrieve data from a specific scan parameters: @@ -12996,7 +14269,7 @@ paths: $ref: '#/components/schemas/ScanResponse' description: '' patch: - operationId: scans_partial_update + operationId: api_v1_scans_partial_update description: Update certain fields of an existing scan without affecting other fields. summary: Partially update a scan @@ -13033,7 +14306,7 @@ paths: description: '' /api/v1/scans/{id}/cis: get: - operationId: scans_cis_retrieve + operationId: api_v1_scans_cis_retrieve description: Download the CIS Benchmark compliance report as a PDF file. When a provider ships multiple CIS versions, the report is generated for the highest available version. @@ -13100,7 +14373,7 @@ paths: has not started yet /api/v1/scans/{id}/compliance/{name}: get: - operationId: scans_compliance_retrieve + operationId: api_v1_scans_compliance_retrieve description: Download a specific compliance report (e.g., 'cis_1.4_aws') as a CSV file. summary: Retrieve compliance report as CSV @@ -13145,10 +14418,11 @@ paths: description: Compliance report not found, or the scan has no reports yet /api/v1/scans/{id}/compliance/{name}/ocsf: get: - operationId: scans_compliance_ocsf_retrieve + operationId: api_v1_scans_compliance_ocsf_retrieve description: Download a specific compliance report as an OCSF JSON file. Only universal frameworks that declare an output configuration produce this artifact - (currently 'dora' and 'csa_ccm_4.0'); any other framework returns 404. + (currently 'dora_2022_2554', 'csa_ccm_4.0' and 'cis_controls_8.1'); any other + framework returns 404. summary: Retrieve compliance report as OCSF JSON parameters: - in: query @@ -13174,7 +14448,7 @@ paths: name: name schema: type: string - description: The compliance report name, like 'dora' + description: The compliance report name, like 'dora_2022_2554' required: true tags: - Scan @@ -13192,7 +14466,7 @@ paths: an OCSF export, or the scan has no reports yet /api/v1/scans/{id}/csa: get: - operationId: scans_csa_retrieve + operationId: api_v1_scans_csa_retrieve description: Download CSA Cloud Controls Matrix (CCM) v4.0 compliance report as a PDF file. summary: Retrieve CSA CCM compliance report @@ -13258,7 +14532,7 @@ paths: task has not started yet /api/v1/scans/{id}/ens: get: - operationId: scans_ens_retrieve + operationId: api_v1_scans_ens_retrieve description: Download ENS RD2022 compliance report (e.g., 'ens_rd2022_aws') as a PDF file. summary: Retrieve ENS RD2022 compliance report @@ -13324,7 +14598,7 @@ paths: has not started yet /api/v1/scans/{id}/nis2: get: - operationId: scans_nis2_retrieve + operationId: api_v1_scans_nis2_retrieve description: Download NIS2 compliance report (Directive (EU) 2022/2555) as a PDF file. summary: Retrieve NIS2 compliance report @@ -13390,7 +14664,7 @@ paths: task has not started yet /api/v1/scans/{id}/report: get: - operationId: scans_report_retrieve + operationId: api_v1_scans_report_retrieve description: Returns a ZIP file containing the requested report summary: Download ZIP report parameters: @@ -13428,7 +14702,7 @@ paths: not started yet /api/v1/scans/{id}/threatscore: get: - operationId: scans_threatscore_retrieve + operationId: api_v1_scans_threatscore_retrieve description: Download a specific threatscore report (e.g., 'prowler_threatscore_aws') as a PDF file. summary: Retrieve threatscore report @@ -13494,7 +14768,7 @@ paths: generation task has not started yet /api/v1/schedules/daily: post: - operationId: schedules_daily_create + operationId: api_v1_schedules_daily_create description: Schedules a daily scan for the specified provider. This endpoint creates a periodic task that will execute a scan every 24 hours. summary: Create a daily schedule scan for a given provider @@ -13538,7 +14812,7 @@ paths: description: '' /api/v1/tasks: get: - operationId: tasks_list + operationId: api_v1_tasks_list description: Retrieve a list of all tasks with options for filtering by name, state, and other criteria. summary: List all tasks @@ -13638,7 +14912,7 @@ paths: description: '' /api/v1/tasks/{id}: get: - operationId: tasks_retrieve + operationId: api_v1_tasks_retrieve description: Fetch detailed information about a specific task by its ID. summary: Retrieve data from a specific task parameters: @@ -13678,7 +14952,7 @@ paths: $ref: '#/components/schemas/TaskResponse' description: '' delete: - operationId: tasks_destroy + operationId: api_v1_tasks_destroy description: Try to revoke a task using its ID. Only tasks that are not yet in progress can be revoked. summary: Revoke a task @@ -13718,7 +14992,7 @@ paths: description: '' /api/v1/tenants: get: - operationId: tenants_list + operationId: api_v1_tenants_list description: Retrieve a list of all tenants with options for filtering by various criteria. summary: List all tenants @@ -13824,7 +15098,7 @@ paths: $ref: '#/components/schemas/PaginatedTenantList' description: '' post: - operationId: tenants_create + operationId: api_v1_tenants_create description: Add a new tenant to the system by providing the required tenant details. summary: Create a new tenant @@ -13853,7 +15127,7 @@ paths: description: '' /api/v1/tenants/{id}: get: - operationId: tenants_retrieve + operationId: api_v1_tenants_retrieve description: Fetch detailed information about a specific tenant by their ID. summary: Retrieve data from a tenant parameters: @@ -13888,7 +15162,7 @@ paths: $ref: '#/components/schemas/TenantResponse' description: '' patch: - operationId: tenants_partial_update + operationId: api_v1_tenants_partial_update description: Update certain fields of an existing tenant's information without affecting other fields. summary: Partially update a tenant @@ -13924,7 +15198,7 @@ paths: $ref: '#/components/schemas/TenantResponse' description: '' delete: - operationId: tenants_destroy + operationId: api_v1_tenants_destroy description: Remove a tenant from the system by their ID. summary: Delete a tenant parameters: @@ -13944,7 +15218,7 @@ paths: description: No response body /api/v1/tenants/{tenant_pk}/memberships: get: - operationId: tenants_memberships_list + operationId: api_v1_tenants_memberships_list description: List the membership details of users in a tenant you are a part of. summary: List tenant memberships @@ -14062,7 +15336,7 @@ paths: description: '' /api/v1/tenants/{tenant_pk}/memberships/{id}: delete: - operationId: tenants_memberships_destroy + operationId: api_v1_tenants_memberships_destroy description: 'Delete a user''s membership from a tenant. This action: (1) removes the membership, (2) revokes all refresh tokens for the expelled user, (3) removes their role grants for this tenant, (4) cleans up orphaned roles, and @@ -14093,7 +15367,7 @@ paths: description: No response body /api/v1/tenants/invitations: get: - operationId: tenants_invitations_list + operationId: api_v1_tenants_invitations_list description: Retrieve a list of all tenant invitations with options for filtering by various criteria. summary: List all invitations @@ -14275,7 +15549,7 @@ paths: $ref: '#/components/schemas/PaginatedInvitationList' description: '' post: - operationId: tenants_invitations_create + operationId: api_v1_tenants_invitations_create description: Add a new tenant invitation to the system by providing the required invitation details. The invited user will have to accept the invitations or create an account using the given code. @@ -14305,7 +15579,7 @@ paths: description: '' /api/v1/tenants/invitations/{id}: get: - operationId: tenants_invitations_retrieve + operationId: api_v1_tenants_invitations_retrieve description: Fetch detailed information about a specific invitation by its ID. summary: Retrieve data from a tenant invitation parameters: @@ -14346,7 +15620,7 @@ paths: $ref: '#/components/schemas/InvitationResponse' description: '' patch: - operationId: tenants_invitations_partial_update + operationId: api_v1_tenants_invitations_partial_update description: Update certain fields of an existing tenant invitation's information without affecting other fields. summary: Partially update a tenant invitation @@ -14381,7 +15655,7 @@ paths: $ref: '#/components/schemas/InvitationUpdateResponse' description: '' delete: - operationId: tenants_invitations_destroy + operationId: api_v1_tenants_invitations_destroy description: Revoke a tenant invitation from the system by their ID. summary: Revoke a tenant invitation parameters: @@ -14400,7 +15674,7 @@ paths: description: No response body /api/v1/tokens: post: - operationId: tokens_create + operationId: api_v1_tokens_create description: Obtain a token by providing valid credentials and an optional tenant ID. summary: Obtain a token @@ -14430,7 +15704,7 @@ paths: description: '' /api/v1/tokens/refresh: post: - operationId: tokens_refresh_create + operationId: api_v1_tokens_refresh_create description: Refresh an access token by providing a valid refresh token. Former refresh tokens are invalidated when a new one is issued. summary: Refresh a token @@ -14460,7 +15734,7 @@ paths: description: '' /api/v1/tokens/switch: post: - operationId: tokens_switch_create + operationId: api_v1_tokens_switch_create description: Switch tenant by providing a valid tenant ID. The authenticated user must belong to the tenant. summary: Switch tenant using a valid tenant ID @@ -14489,7 +15763,7 @@ paths: description: '' /api/v1/users: get: - operationId: users_list + operationId: api_v1_users_list description: Retrieve a list of all users with options for filtering by various criteria. summary: List all users @@ -14620,7 +15894,7 @@ paths: $ref: '#/components/schemas/PaginatedUserList' description: '' post: - operationId: users_create + operationId: api_v1_users_create description: Create a new user account by providing the necessary registration details. summary: Register a new user @@ -14657,7 +15931,7 @@ paths: description: '' /api/v1/users/{id}: get: - operationId: users_retrieve + operationId: api_v1_users_retrieve description: Fetch detailed information about an authenticated user. summary: Retrieve a user's information parameters: @@ -14708,7 +15982,7 @@ paths: $ref: '#/components/schemas/UserResponse' description: '' patch: - operationId: users_partial_update + operationId: api_v1_users_partial_update description: Partially update information about a user. summary: Update user information parameters: @@ -14743,7 +16017,7 @@ paths: $ref: '#/components/schemas/UserUpdateResponse' description: '' delete: - operationId: users_destroy + operationId: api_v1_users_destroy description: Remove the current user account from the system. summary: Delete the user account parameters: @@ -14763,7 +16037,7 @@ paths: description: No response body /api/v1/users/{id}/relationships/roles: post: - operationId: users_relationships_roles_create + operationId: api_v1_users_relationships_roles_create description: Add a new user-roles relationship to the system by providing the required user-roles details. summary: Create a new user-roles relationship @@ -14789,7 +16063,7 @@ paths: '400': description: Bad request (e.g., relationship already exists) patch: - operationId: users_relationships_roles_partial_update + operationId: api_v1_users_relationships_roles_partial_update description: Update the user-roles relationship information without affecting other fields. If the update would remove MANAGE_ACCOUNT from the last remaining user in the tenant, the API rejects the request with a 400 response. @@ -14814,7 +16088,7 @@ paths: '204': description: Relationship updated successfully delete: - operationId: users_relationships_roles_destroy + operationId: api_v1_users_relationships_roles_destroy description: Remove the user-roles relationship from the system by their ID. If removing MANAGE_ACCOUNT would take it away from the last remaining user in the tenant, the API rejects the request with a 400 response. Users also @@ -14830,7 +16104,7 @@ paths: description: Relationship deleted successfully /api/v1/users/{user_pk}/memberships: get: - operationId: users_memberships_list + operationId: api_v1_users_memberships_list description: Retrieve a list of all user memberships with options for filtering by various criteria. summary: List user memberships @@ -14943,7 +16217,7 @@ paths: description: '' /api/v1/users/{user_pk}/memberships/{id}: get: - operationId: users_memberships_retrieve + operationId: api_v1_users_memberships_retrieve description: Fetch detailed information about a specific user membership by their ID. summary: Retrieve membership data from the user @@ -14988,7 +16262,7 @@ paths: description: '' /api/v1/users/me: get: - operationId: users_me_retrieve + operationId: api_v1_users_me_retrieve description: Fetch detailed information about the authenticated user. summary: Retrieve the current user's information parameters: @@ -20271,18 +21545,22 @@ components: properties: okta_client_id: type: string - description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + description: Client ID of the Okta API Services app used for + OAuth 2.0 private-key JWT authentication. okta_private_key: type: string - description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + description: PEM-encoded private key whose matching public + key (JWK) is registered on the Okta service app. okta_scopes: type: array items: type: string - description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + description: OAuth scopes to request. Optional; defaults to + the minimum set required to run the currently enabled Okta + checks. required: - - okta_client_id - - okta_private_key + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -21314,7 +22592,7 @@ components: * `googleworkspace` - Google Workspace * `vercel` - Vercel * `okta` - Okta - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 uid: type: string title: Unique identifier for the provider, set by the provider @@ -21437,7 +22715,7 @@ components: - vercel - okta type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 description: |- Type of provider to create. @@ -21511,7 +22789,7 @@ components: - vercel - okta type: string - x-spec-enum-id: 91f917e0c3ab97e8 + x-spec-enum-id: 203afc16daac9b64 description: |- Type of provider to create. @@ -22385,18 +23663,21 @@ components: properties: okta_client_id: type: string - description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + description: Client ID of the Okta API Services app used for OAuth + 2.0 private-key JWT authentication. okta_private_key: type: string - description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + description: PEM-encoded private key whose matching public key + (JWK) is registered on the Okta service app. okta_scopes: type: array items: type: string - description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + description: OAuth scopes to request. Optional; defaults to the + minimum set required to run the currently enabled Okta checks. required: - - okta_client_id - - okta_private_key + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -22827,18 +24108,22 @@ components: properties: okta_client_id: type: string - description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + description: Client ID of the Okta API Services app used for + OAuth 2.0 private-key JWT authentication. okta_private_key: type: string - description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + description: PEM-encoded private key whose matching public + key (JWK) is registered on the Okta service app. okta_scopes: type: array items: type: string - description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + description: OAuth scopes to request. Optional; defaults to + the minimum set required to run the currently enabled Okta + checks. required: - - okta_client_id - - okta_private_key + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: @@ -23279,18 +24564,21 @@ components: properties: okta_client_id: type: string - description: Client ID of the Okta API Services app used for OAuth 2.0 private-key JWT authentication. + description: Client ID of the Okta API Services app used for OAuth + 2.0 private-key JWT authentication. okta_private_key: type: string - description: PEM-encoded private key whose matching public key (JWK) is registered on the Okta service app. + description: PEM-encoded private key whose matching public key + (JWK) is registered on the Okta service app. okta_scopes: type: array items: type: string - description: OAuth scopes to request. Optional; defaults to the minimum set required to run the currently enabled Okta checks. + description: OAuth scopes to request. Optional; defaults to the + minimum set required to run the currently enabled Okta checks. required: - - okta_client_id - - okta_private_key + - okta_client_id + - okta_private_key - type: object title: Vercel API Token properties: diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py index 4f787b1f5a..4fab2d37b9 100644 --- a/api/src/backend/api/tests/test_rbac.py +++ b/api/src/backend/api/tests/test_rbac.py @@ -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 diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 79c0ceacd3..c980296510 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -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 ): diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index b488525a0a..588c65c30d 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -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): diff --git a/api/src/backend/tasks/tests/test_attack_paths_scan.py b/api/src/backend/tasks/tests/test_attack_paths_scan.py index 01c50c9522..ed483d33d6 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -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 ): diff --git a/api/uv.lock b/api/uv.lock index 80b16aeac8..020f3bde4f 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -4762,7 +4762,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.33.0" +version = "1.34.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, diff --git a/docs/docs.json b/docs/docs.json index fe3bc8cd51..0de5e1ff09 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" ] }, diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 598b2ac44a..bb2a1fe633 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -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" ``` diff --git a/docs/images/prowler-app/attack-paths/execute-query.png b/docs/images/prowler-app/attack-paths/execute-query.png index 3872f0de2f..683c7c5bd3 100644 Binary files a/docs/images/prowler-app/attack-paths/execute-query.png and b/docs/images/prowler-app/attack-paths/execute-query.png differ diff --git a/docs/images/prowler-app/attack-paths/fullscreen-mode.png b/docs/images/prowler-app/attack-paths/fullscreen-mode.png index c5156925ba..190d973638 100644 Binary files a/docs/images/prowler-app/attack-paths/fullscreen-mode.png and b/docs/images/prowler-app/attack-paths/fullscreen-mode.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-filtered.png b/docs/images/prowler-app/attack-paths/graph-filtered.png index 3ff4822a26..3eddf04473 100644 Binary files a/docs/images/prowler-app/attack-paths/graph-filtered.png and b/docs/images/prowler-app/attack-paths/graph-filtered.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-visualization.png b/docs/images/prowler-app/attack-paths/graph-visualization.png index 2ea160a6b2..d7f2a747eb 100644 Binary files a/docs/images/prowler-app/attack-paths/graph-visualization.png and b/docs/images/prowler-app/attack-paths/graph-visualization.png differ diff --git a/docs/images/prowler-app/attack-paths/navigation.png b/docs/images/prowler-app/attack-paths/navigation.png index d133c7f6b4..0526e05e5c 100644 Binary files a/docs/images/prowler-app/attack-paths/navigation.png and b/docs/images/prowler-app/attack-paths/navigation.png differ diff --git a/docs/images/prowler-app/attack-paths/node-details.png b/docs/images/prowler-app/attack-paths/node-details.png index 9343eedd7d..8b02128604 100644 Binary files a/docs/images/prowler-app/attack-paths/node-details.png and b/docs/images/prowler-app/attack-paths/node-details.png differ diff --git a/docs/images/prowler-app/attack-paths/query-parameters.png b/docs/images/prowler-app/attack-paths/query-parameters.png index 9f44f81838..683c7c5bd3 100644 Binary files a/docs/images/prowler-app/attack-paths/query-parameters.png and b/docs/images/prowler-app/attack-paths/query-parameters.png differ diff --git a/docs/images/prowler-app/attack-paths/query-selector.png b/docs/images/prowler-app/attack-paths/query-selector.png index d8b7414156..828183fdbf 100644 Binary files a/docs/images/prowler-app/attack-paths/query-selector.png and b/docs/images/prowler-app/attack-paths/query-selector.png differ diff --git a/docs/images/prowler-app/attack-paths/scan-list-table.png b/docs/images/prowler-app/attack-paths/scan-list-table.png index 092b539dd0..45d0af41dc 100644 Binary files a/docs/images/prowler-app/attack-paths/scan-list-table.png and b/docs/images/prowler-app/attack-paths/scan-list-table.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-note-modal.png b/docs/images/prowler-app/findings-triage/findings-triage-note-modal.png new file mode 100644 index 0000000000..9d7926997d Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-note-modal.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-status-dropdown.png b/docs/images/prowler-app/findings-triage/findings-triage-status-dropdown.png new file mode 100644 index 0000000000..7fb7790610 Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-status-dropdown.png differ diff --git a/docs/images/prowler-app/findings-triage/findings-triage-table.png b/docs/images/prowler-app/findings-triage/findings-triage-table.png new file mode 100644 index 0000000000..c8fa79e379 Binary files /dev/null and b/docs/images/prowler-app/findings-triage/findings-triage-table.png differ diff --git a/docs/user-guide/providers/azure/resource-groups.mdx b/docs/user-guide/providers/azure/resource-groups.mdx new file mode 100644 index 0000000000..323193ea49 --- /dev/null +++ b/docs/user-guide/providers/azure/resource-groups.mdx @@ -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 ... +``` + +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 --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. + + +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. + diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx index c463e3144b..b07aebcc4f 100644 --- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -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. 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. Attack Paths navigation menu entry -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. - 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. ## 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. -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. Attack Paths right panel with query selected and execute button ## 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. Attack Paths graph showing nodes, edges, and legend ## 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. Attack Paths graph filtered to show paths through a selected node @@ -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 Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within the graph area. -## 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. Attack Paths node detail panel showing properties and related findings @@ -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. Attack Paths fullscreen mode with graph and node detail side panel +## 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 diff --git a/docs/user-guide/tutorials/prowler-app-findings-triage.mdx b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx new file mode 100644 index 0000000000..68b1af5a32 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-findings-triage.mdx @@ -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" + + + +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. + + + +## What Is Findings Triage? + +Findings Triage adds a **Triage** status and team note workflow to individual finding rows. It is available from: + +- Expanded rows in **Finding Groups** +- Standalone finding tables +- Finding and resource detail drawers, including related findings tables + +Finding Groups rows do not show triage controls because a group row represents several findings. Expand a group to work with each affected resource. + +![Findings Triage Table](/images/prowler-app/findings-triage/findings-triage-table.png) + +## Required Permissions + +To update triage statuses and notes, the user role must have the **Manage Scans** permission. For more information, see [Role-Based Access Control (RBAC)](/user-guide/tutorials/prowler-app-rbac). + +Users without this permission can still see existing triage context when it is available, but cannot change statuses or save notes. + +## Triage Statuses + +The status selector includes manual statuses. Prowler also sets automatic statuses after scans. + +| Status | Type | Use It When | +| --- | --- | --- | +| **Open** | Manual | A failed finding has not been reviewed yet. A failed finding with no saved triage state also appears as **Open**. | +| **Under Review** | Manual | A team is investigating the finding. | +| **Remediating** | Manual | Work is in progress to fix the finding. | +| **Risk Accepted** | Manual | The team accepts the risk and wants to mute the finding. | +| **False Positive** | Manual | The finding does not apply and should be muted. | +| **Resolved** | Automatic | A finding changed from `FAIL` to `PASS` in a later scan. A passed finding with no saved triage state also appears as **Resolved**. | +| **Reopened** | Automatic | A finding changed from `PASS` to `FAIL` in a later scan. | + +![Findings Triage Status Selector](/images/prowler-app/findings-triage/findings-triage-status-dropdown.png) + +Resolved and Reopened are not manual selector options. + +These automatic states keep triage tied to the finding UID across scans, even when each scan creates a new finding snapshot. + +## Change a Triage Status + + + + Go to **Findings** in Prowler Cloud. + + + Expand a Finding Group, open a resource findings table, or use a standalone finding row. + + + In the **Triage** column, click the current status. + + + Select **Open**, **Under Review**, **Remediating**, **Risk Accepted**, or **False Positive**. + + + +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. + + + + On an individual finding row, click the actions menu. + + + Click **Add Triage Note**. If a note already exists, click **Open note**. + + + Optionally change the status, then write the note. + + + Click **Save changes**. + + + +![Findings Triage Note Modal](/images/prowler-app/findings-triage/findings-triage-note-modal.png) + +To remove an existing note, clear the note text and save the change. + +## Mutelist Behavior + +Findings Triage uses Mutelist when a status means the finding should be muted: + +- **Risk Accepted** creates a mute rule because the team accepts the finding as a known risk. +- **False Positive** creates a mute rule because the finding should not count as an active issue. + +Use [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) to review, disable, or delete mute rules created through this workflow. For pattern-based muting, use [Advanced Mutelist](/user-guide/tutorials/prowler-app-mute-findings). + + +Muting a finding does not fix the underlying configuration. Review the finding before using **Risk Accepted** or **False Positive**. + + +## 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. diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index c201482c6a..a339537e8d 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -40,6 +40,11 @@ Follow these steps to edit a user of your account: Edit User Details + +Users can edit their own account details. Editing another user's account details requires the **Invite and Manage Users** or **admin** permission. + + + #### Removing a User Follow these steps to remove a user of your account: diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index c0da045603..eb140e7c09 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -39,6 +39,8 @@ "rolesanywhere:ListTagsForResource", "rolesanywhere:ListTrustAnchors", "s3:GetAccountPublicAccessBlock", + "s3:GetObjectAcl", + "s3:ListBucket", "shield:DescribeProtection", "shield:GetSubscriptionState", "securityhub:BatchImportFindings", diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 4abc4f1384..5730cc4a1e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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) diff --git a/prowler/config/config.py b/prowler/config/config.py index c664745000..4d0b631606 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -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" diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index d542da0b75..3a059e11d0 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -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: diff --git a/prowler/config/schema/aws.py b/prowler/config/schema/aws.py index 4a31458f7d..4e52093029 100644 --- a/prowler/config/schema/aws.py +++ b/prowler/config/schema/aws.py @@ -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." + ), + ) diff --git a/prowler/lib/check/compliance_config_eval.py b/prowler/lib/check/compliance_config_eval.py index 763e1389c2..0024d41e3f 100644 --- a/prowler/lib/check/compliance_config_eval.py +++ b/prowler/lib/check/compliance_config_eval.py @@ -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 diff --git a/prowler/providers/aws/services/s3/s3_bucket_object_public/__init__.py b/prowler/providers/aws/services/s3/s3_bucket_object_public/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.metadata.json b/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.metadata.json new file mode 100644 index 0000000000..197a5aa205 --- /dev/null +++ b/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.metadata.json @@ -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 --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." +} diff --git a/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.py b/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.py new file mode 100644 index 0000000000..9912da561b --- /dev/null +++ b/prowler/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public.py @@ -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 diff --git a/prowler/providers/aws/services/s3/s3_service.py b/prowler/providers/aws/services/s3/s3_service.py index 00605fbe6e..94768138df 100644 --- a/prowler/providers/aws/services/s3/s3_service.py +++ b/prowler/providers/aws/services/s3/s3_service.py @@ -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 diff --git a/prowler/providers/azure/azure_provider.py b/prowler/providers/azure/azure_provider.py index cb27bdfdb1..8b399cdc48 100644 --- a/prowler/providers/azure/azure_provider.py +++ b/prowler/providers/azure/azure_provider.py @@ -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, diff --git a/prowler/providers/azure/lib/arguments/arguments.py b/prowler/providers/azure/lib/arguments/arguments.py index 2b624a3f23..87bea948aa 100644 --- a/prowler/providers/azure/lib/arguments/arguments.py +++ b/prowler/providers/azure/lib/arguments/arguments.py @@ -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): diff --git a/prowler/providers/azure/lib/service/service.py b/prowler/providers/azure/lib/service/service.py index a0a832ca01..ae9b127647 100644 --- a/prowler/providers/azure/lib/service/service.py +++ b/prowler/providers/azure/lib/service/service.py @@ -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: diff --git a/prowler/providers/azure/services/aisearch/aisearch_service.py b/prowler/providers/azure/services/aisearch/aisearch_service.py index 3f482a41a5..01c6a458f7 100644 --- a/prowler/providers/azure/services/aisearch/aisearch_service.py +++ b/prowler/providers/azure/services/aisearch/aisearch_service.py @@ -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( { diff --git a/prowler/providers/azure/services/aks/aks_service.py b/prowler/providers/azure/services/aks/aks_service.py index 081edd7b17..0bd7c4d63c 100644 --- a/prowler/providers/azure/services/aks/aks_service.py +++ b/prowler/providers/azure/services/aks/aks_service.py @@ -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): diff --git a/prowler/providers/azure/services/apim/apim_service.py b/prowler/providers/azure/services/apim/apim_service.py index 98fb00f276..3186716ddb 100644 --- a/prowler/providers/azure/services/apim/apim_service.py +++ b/prowler/providers/azure/services/apim/apim_service.py @@ -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( diff --git a/prowler/providers/azure/services/app/app_service.py b/prowler/providers/azure/services/app/app_service.py index 201cd6a344..83d23be516 100644 --- a/prowler/providers/azure/services/app/app_service.py +++ b/prowler/providers/azure/services/app/app_service.py @@ -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 diff --git a/prowler/providers/azure/services/appinsights/appinsights_service.py b/prowler/providers/azure/services/appinsights/appinsights_service.py index 918a0f1b0f..6e92a7b275 100644 --- a/prowler/providers/azure/services/appinsights/appinsights_service.py +++ b/prowler/providers/azure/services/appinsights/appinsights_service.py @@ -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( diff --git a/prowler/providers/azure/services/containerregistry/containerregistry_service.py b/prowler/providers/azure/services/containerregistry/containerregistry_service.py index ee6cce39f2..c44a0c7ef1 100644 --- a/prowler/providers/azure/services/containerregistry/containerregistry_service.py +++ b/prowler/providers/azure/services/containerregistry/containerregistry_service.py @@ -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) diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py index e7c53799a7..37b06da1c5 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py @@ -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( diff --git a/prowler/providers/azure/services/databricks/databricks_service.py b/prowler/providers/azure/services/databricks/databricks_service.py index b7367d3cbb..128495a2c7 100644 --- a/prowler/providers/azure/services/databricks/databricks_service.py +++ b/prowler/providers/azure/services/databricks/databricks_service.py @@ -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( diff --git a/prowler/providers/azure/services/defender/defender_service.py b/prowler/providers/azure/services/defender/defender_service.py index 7da96cd8ec..b4ce4239cc 100644 --- a/prowler/providers/azure/services/defender/defender_service.py +++ b/prowler/providers/azure/services/defender/defender_service.py @@ -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) diff --git a/prowler/providers/azure/services/keyvault/keyvault_service.py b/prowler/providers/azure/services/keyvault/keyvault_service.py index 9fb3fd98af..e5b2e76427 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_service.py +++ b/prowler/providers/azure/services/keyvault/keyvault_service.py @@ -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 diff --git a/prowler/providers/azure/services/mysql/mysql_service.py b/prowler/providers/azure/services/mysql/mysql_service.py index b3a386a193..2898d8445c 100644 --- a/prowler/providers/azure/services/mysql/mysql_service.py +++ b/prowler/providers/azure/services/mysql/mysql_service.py @@ -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) diff --git a/prowler/providers/azure/services/network/network_service.py b/prowler/providers/azure/services/network/network_service.py index a924cf9609..54cb02989b 100644 --- a/prowler/providers/azure/services/network/network_service.py +++ b/prowler/providers/azure/services/network/network_service.py @@ -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 diff --git a/prowler/providers/azure/services/policy/policy_service.py b/prowler/providers/azure/services/policy/policy_service.py index 1d1381202f..663e9b76a3 100644 --- a/prowler/providers/azure/services/policy/policy_service.py +++ b/prowler/providers/azure/services/policy/policy_service.py @@ -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( diff --git a/prowler/providers/azure/services/postgresql/postgresql_service.py b/prowler/providers/azure/services/postgresql/postgresql_service.py index 681c57a32c..e7fe98fe79 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_service.py +++ b/prowler/providers/azure/services/postgresql/postgresql_service.py @@ -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 diff --git a/prowler/providers/azure/services/recovery/recovery_service.py b/prowler/providers/azure/services/recovery/recovery_service.py index 38645219bd..6c414ee446 100644 --- a/prowler/providers/azure/services/recovery/recovery_service.py +++ b/prowler/providers/azure/services/recovery/recovery_service.py @@ -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, diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_service.py b/prowler/providers/azure/services/sqlserver/sqlserver_service.py index af02dace0d..274025fd7b 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_service.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_service.py @@ -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( diff --git a/prowler/providers/azure/services/storage/storage_service.py b/prowler/providers/azure/services/storage/storage_service.py index 74b8b3da30..863b33256e 100644 --- a/prowler/providers/azure/services/storage/storage_service.py +++ b/prowler/providers/azure/services/storage/storage_service.py @@ -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: diff --git a/prowler/providers/azure/services/vm/vm_service.py b/prowler/providers/azure/services/vm/vm_service.py index b20f4b5678..8ef27c57f4 100644 --- a/prowler/providers/azure/services/vm/vm_service.py +++ b/prowler/providers/azure/services/vm/vm_service.py @@ -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( diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 8c2d90b837..981d11fe00 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -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, diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index 4dae094d90..d9cbdcee90 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -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. diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index f8713aff8a..a083eb1c3c 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -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] = [] diff --git a/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/__init__.py b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.metadata.json b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.metadata.json new file mode 100644 index 0000000000..0e4da9420d --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.metadata.json @@ -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 -PolicyScopeGroupId -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": "" +} diff --git a/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.py b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.py new file mode 100644 index 0000000000..d4c786b338 --- /dev/null +++ b/prowler/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps.py @@ -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 diff --git a/prowler/providers/m365/services/exchange/exchange_service.py b/prowler/providers/m365/services/exchange/exchange_service.py index 3ce6528aa9..c4e4b309a4 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -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. diff --git a/pyproject.toml b/pyproject.toml index a645bd0d10..9b7452b1f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/lib/check/compliance_config_eval_test.py b/tests/lib/check/compliance_config_eval_test.py index 4acec9bb4c..74537344c4 100644 --- a/tests/lib/check/compliance_config_eval_test.py +++ b/tests/lib/check/compliance_config_eval_test.py @@ -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): diff --git a/tests/providers/aws/services/s3/s3_bucket_object_public/__init__.py b/tests/providers/aws/services/s3/s3_bucket_object_public/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public_test.py b/tests/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public_test.py new file mode 100644 index 0000000000..2ac143ff47 --- /dev/null +++ b/tests/providers/aws/services/s3/s3_bucket_object_public/s3_bucket_object_public_test.py @@ -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 diff --git a/tests/providers/aws/services/s3/s3_service_test.py b/tests/providers/aws/services/s3/s3_service_test.py index 7dec192f2e..d6d9575a11 100644 --- a/tests/providers/aws/services/s3/s3_service_test.py +++ b/tests/providers/aws/services/s3/s3_service_test.py @@ -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 + ) diff --git a/tests/providers/azure/azure_fixtures.py b/tests/providers/azure/azure_fixtures.py index 84d43fd2c3..d095645e1f 100644 --- a/tests/providers/azure/azure_fixtures.py +++ b/tests/providers/azure/azure_fixtures.py @@ -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 diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py index 1d9aa97e6b..b4fba94691 100644 --- a/tests/providers/azure/azure_provider_test.py +++ b/tests/providers/azure/azure_provider_test.py @@ -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 diff --git a/tests/providers/azure/services/aisearch/aisearch_service_test.py b/tests/providers/azure/services/aisearch/aisearch_service_test.py index ff041e7eab..e8bc94675f 100644 --- a/tests/providers/azure/services/aisearch/aisearch_service_test.py +++ b/tests/providers/azure/services/aisearch/aisearch_service_test.py @@ -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" + ) diff --git a/tests/providers/azure/services/aks/aks_service_test.py b/tests/providers/azure/services/aks/aks_service_test.py index 8644dcb03b..494c224b6b 100644 --- a/tests/providers/azure/services/aks/aks_service_test.py +++ b/tests/providers/azure/services/aks/aks_service_test.py @@ -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" + ) diff --git a/tests/providers/azure/services/apim/apim_service_test.py b/tests/providers/azure/services/apim/apim_service_test.py index f2141aee6b..cb78225e99 100644 --- a/tests/providers/azure/services/apim/apim_service_test.py +++ b/tests/providers/azure/services/apim/apim_service_test.py @@ -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" + ) diff --git a/tests/providers/azure/services/app/app_service_test.py b/tests/providers/azure/services/app/app_service_test.py index cc33c662a1..8cbc5ad54f 100644 --- a/tests/providers/azure/services/app/app_service_test.py +++ b/tests/providers/azure/services/app/app_service_test.py @@ -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" + ) diff --git a/tests/providers/azure/services/appinsights/appinsights_service_test.py b/tests/providers/azure/services/appinsights/appinsights_service_test.py index 6d821ff3e6..7a0c80acc6 100644 --- a/tests/providers/azure/services/appinsights/appinsights_service_test.py +++ b/tests/providers/azure/services/appinsights/appinsights_service_test.py @@ -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" + ) diff --git a/tests/providers/azure/services/containerregistry/containerregistry_service_test.py b/tests/providers/azure/services/containerregistry/containerregistry_service_test.py index 3e4a02406e..c3468ca086 100644 --- a/tests/providers/azure/services/containerregistry/containerregistry_service_test.py +++ b/tests/providers/azure/services/containerregistry/containerregistry_service_test.py @@ -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" + ) diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_service_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_service_test.py index 09293d7dcd..b1cc8d0e1b 100644 --- a/tests/providers/azure/services/cosmosdb/cosmosdb_service_test.py +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_service_test.py @@ -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" + ) diff --git a/tests/providers/azure/services/databricks/databricks_service_test.py b/tests/providers/azure/services/databricks/databricks_service_test.py index f663d81fe2..669558f0e0 100644 --- a/tests/providers/azure/services/databricks/databricks_service_test.py +++ b/tests/providers/azure/services/databricks/databricks_service_test.py @@ -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" + ) diff --git a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py index 75f3d5014a..8a57fa0fcb 100644 --- a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py +++ b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py b/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py index 1e567ac153..e9030a2a52 100644 --- a/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py +++ b/tests/providers/azure/services/defender/defender_assessments_vm_endpoint_protection_installed/defender_assessments_vm_endpoint_protection_installed_test.py @@ -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 = { diff --git a/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py b/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py index ebece2e029..220fbbf4bf 100644 --- a/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py +++ b/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py b/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py index 9a99281e94..bfc540f0eb 100644 --- a/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py +++ b/tests/providers/azure/services/defender/defender_auto_provisioning_log_analytics_agent_vms_on/defender_auto_provisioning_log_analytics_agent_vms_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py b/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py index eeddb61012..b5cf053127 100644 --- a/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py +++ b/tests/providers/azure/services/defender/defender_auto_provisioning_vulnerabilty_assessments_machines_on/defender_auto_provisioning_vulnerabilty_assessments_machines_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py b/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py index 510a995692..3eb5ffd4a5 100644 --- a/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py +++ b/tests/providers/azure/services/defender/defender_container_images_resolved_vulnerabilities/defender_container_images_resolved_vulnerabilities_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py b/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py index 977ee8acdb..63e89a844a 100644 --- a/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py +++ b/tests/providers/azure/services/defender/defender_container_images_scan_enabled/defender_container_images_scan_enabled_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py index b2528e28e7..9164b7dfdb 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_app_services_is_on/defender_ensure_defender_for_app_services_is_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py index 357e3ca9e7..2c83113dc6 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_arm_is_on/defender_ensure_defender_for_arm_is_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py index c10314042b..f4ff5f6471 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_azure_sql_databases_is_on/defender_ensure_defender_for_azure_sql_databases_is_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py index 7ff728add9..02563454a5 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_containers_is_on/defender_ensure_defender_for_containers_is_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py index 351f38d97f..a48b3678fe 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_cosmosdb_is_on/defender_ensure_defender_for_cosmosdb_is_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py index 48cbc57ad1..8cde319553 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_databases_is_on/defender_ensure_defender_for_databases_is_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py index 6b50ea4c5f..e41f6499d8 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_dns_is_on/defender_ensure_defender_for_dns_is_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py index f587a92961..e32d7b6b24 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_keyvault_is_on/defender_ensure_defender_for_keyvault_is_on_test.py @@ -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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py index dc28fb3bb2..7d74712097 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_os_relational_databases_is_on/defender_ensure_defender_for_os_relational_databases_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_os_relational_databases_is_on: def test_defender_no_os_relational_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_os_relational_databases_is_on: def test_defender_os_relational_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: { @@ -81,6 +83,7 @@ class Test_defender_ensure_defender_for_os_relational_databases_is_on: def test_defender_os_relational_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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py index 226b26ad3a..d8d60d0507 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_server_is_on/defender_ensure_defender_for_server_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_server_is_on: def test_defender_no_server(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_server_is_on: def test_defender_server_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_server_is_on: def test_defender_server_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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py index 1907cdbb6c..1f63aed4c8 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_sql_servers_is_on/defender_ensure_defender_for_sql_servers_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_sql_servers_is_on: def test_defender_no_server(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_sql_servers_is_on: def test_defender_server_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_sql_servers_is_on: def test_defender_server_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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py index f5eee6879a..d534db195f 100644 --- a/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_defender_for_storage_is_on/defender_ensure_defender_for_storage_is_on_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_defender_for_storage_is_on: def test_defender_no_server(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_storage_is_on: def test_defender_server_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_storage_is_on: def test_defender_server_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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py index f4ac17c5ae..54b8f17f9a 100644 --- a/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_iot_hub_defender_is_on/defender_ensure_iot_hub_defender_is_on_test.py @@ -15,6 +15,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_iot_hub_defender_is_on: 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.iot_security_solutions = {} @@ -38,6 +39,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_no_iot_hub_solutions(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.iot_security_solutions = {AZURE_SUBSCRIPTION_ID: {}} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} @@ -69,6 +71,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_iot_hub_solution_disabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = { AZURE_SUBSCRIPTION_ID: { @@ -106,6 +109,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: def test_defender_iot_hub_solution_enabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = { AZURE_SUBSCRIPTION_ID: { @@ -145,6 +149,7 @@ class Test_defender_ensure_iot_hub_defender_is_on: resource_id_enabled = str(uuid4()) resource_id_disabled = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.iot_security_solutions = { AZURE_SUBSCRIPTION_ID: { diff --git a/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py b/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py index 7770ab0baf..23abc7beb2 100644 --- a/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_mcas_is_enabled/defender_ensure_mcas_is_enabled_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_mcas_is_enabled: def test_defender_no_settings(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_mcas_is_enabled: def test_defender_mcas_disabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { @@ -79,6 +81,7 @@ class Test_defender_ensure_mcas_is_enabled: def test_defender_mcas_enabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { @@ -120,6 +123,7 @@ class Test_defender_ensure_mcas_is_enabled: def test_defender_mcas_no_settings(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.settings = {AZURE_SUBSCRIPTION_ID: {}} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py index 8d2a3a05f7..b5c3508016 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py @@ -16,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_notify_alerts_severity_is_high: 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_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_critical(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_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_high(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: { @@ -135,6 +138,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_low(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: { @@ -182,6 +186,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_default_security_contact_not_found(self): 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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py index b125320764..d95712806f 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py @@ -16,6 +16,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_notify_emails_to_owners: 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_ensure_notify_emails_to_owners: def test_defender_no_notify_emails_to_owners(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: { @@ -80,6 +82,7 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_notify_emails_to_owners_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.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { @@ -127,6 +130,7 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_notify_emails_to_owners(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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py b/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py index e6a80853dd..4d98db1939 100644 --- a/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_system_updates_are_applied/defender_ensure_system_updates_are_applied_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_system_updates_are_applied: 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_ensure_system_updates_are_applied: def test_defender_machines_no_log_analytics_installed(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: { @@ -89,6 +91,7 @@ class Test_defender_ensure_system_updates_are_applied: ): 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: { @@ -139,6 +142,7 @@ class Test_defender_ensure_system_updates_are_applied: def test_defender_machines_no_system_updates_installed(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: { @@ -191,6 +195,7 @@ class Test_defender_ensure_system_updates_are_applied: ): 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: { diff --git a/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py b/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py index 202e332b3f..2c045b6e49 100644 --- a/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_wdatp_is_enabled/defender_ensure_wdatp_is_enabled_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_wdatp_is_enabled: def test_defender_no_settings(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = {} @@ -37,6 +38,7 @@ class Test_defender_ensure_wdatp_is_enabled: def test_defender_wdatp_disabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { @@ -79,6 +81,7 @@ class Test_defender_ensure_wdatp_is_enabled: def test_defender_wdatp_enabled(self): resource_id = str(uuid4()) defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.settings = { AZURE_SUBSCRIPTION_ID: { @@ -120,6 +123,7 @@ class Test_defender_ensure_wdatp_is_enabled: def test_defender_wdatp_no_settings(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.settings = {AZURE_SUBSCRIPTION_ID: {}} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} diff --git a/tests/providers/azure/services/defender/defender_service_test.py b/tests/providers/azure/services/defender/defender_service_test.py index 4308467263..71457fc6ac 100644 --- a/tests/providers/azure/services/defender/defender_service_test.py +++ b/tests/providers/azure/services/defender/defender_service_test.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.defender.defender_service import ( Assesment, @@ -13,6 +13,8 @@ from prowler.providers.azure.services.defender.defender_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -358,3 +360,263 @@ class Test_Defender_Service_Assessments_None_Handling: "Assessment Unhealthy" ] assert assessment_unhealthy.status == "Unhealthy" + + +DEFENDER_INIT_PATCHES = [ + "prowler.providers.azure.services.defender.defender_service.Defender._get_pricings", + "prowler.providers.azure.services.defender.defender_service.Defender._get_auto_provisioning_settings", + "prowler.providers.azure.services.defender.defender_service.Defender._get_assessments", + "prowler.providers.azure.services.defender.defender_service.Defender._get_settings", + "prowler.providers.azure.services.defender.defender_service.Defender._get_security_contacts", + "prowler.providers.azure.services.defender.defender_service.Defender._get_iot_security_solutions", + "prowler.providers.azure.services.defender.defender_service.Defender._get_jit_policies", +] + + +class Test_Defender_get_iot_security_solutions: + def test_get_iot_security_solutions_no_resource_groups(self): + mock_client = MagicMock() + mock_client.iot_security_solution.list_by_subscription.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = None + + result = defender._get_iot_security_solutions() + + mock_client.iot_security_solution.list_by_subscription.assert_called_once() + mock_client.iot_security_solution.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_iot_security_solutions_with_resource_group(self): + mock_client = MagicMock() + mock_client.iot_security_solution.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = defender._get_iot_security_solutions() + + mock_client.iot_security_solution.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.iot_security_solution.list_by_subscription.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_iot_security_solutions_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = defender._get_iot_security_solutions() + + mock_client.iot_security_solution.list_by_resource_group.assert_not_called() + mock_client.iot_security_solution.list_by_subscription.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + +class Test_Defender_get_jit_policies: + def test_get_jit_policies_no_resource_groups(self): + mock_client = MagicMock() + mock_client.jit_network_access_policies.list.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = None + + result = defender._get_jit_policies() + + mock_client.jit_network_access_policies.list.assert_called_once() + mock_client.jit_network_access_policies.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_jit_policies_with_resource_group(self): + mock_client = MagicMock() + mock_client.jit_network_access_policies.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = defender._get_jit_policies() + + mock_client.jit_network_access_policies.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.jit_network_access_policies.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_jit_policies_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = defender._get_jit_policies() + + mock_client.jit_network_access_policies.list_by_resource_group.assert_not_called() + mock_client.jit_network_access_policies.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_iot_security_solutions_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.iot_security_solution.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = defender._get_iot_security_solutions() + + assert mock_client.iot_security_solution.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_iot_security_solutions_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.iot_security_solution.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + defender._get_iot_security_solutions() + + mock_client.iot_security_solution.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_Defender_get_jit_policies_extra: + def test_get_jit_policies_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.jit_network_access_policies.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = defender._get_jit_policies() + + assert ( + mock_client.jit_network_access_policies.list_by_resource_group.call_count + == 2 + ) + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_jit_policies_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.jit_network_access_policies.list_by_resource_group.return_value = [] + + with ( + patch(DEFENDER_INIT_PATCHES[0], return_value={}), + patch(DEFENDER_INIT_PATCHES[1], return_value={}), + patch(DEFENDER_INIT_PATCHES[2], return_value={}), + patch(DEFENDER_INIT_PATCHES[3], return_value={}), + patch(DEFENDER_INIT_PATCHES[4], return_value={}), + patch(DEFENDER_INIT_PATCHES[5], return_value={}), + patch(DEFENDER_INIT_PATCHES[6], return_value={}), + ): + defender = Defender(set_mocked_azure_provider()) + + defender.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + defender.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + defender._get_jit_policies() + + mock_client.jit_network_access_policies.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals_test.py b/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals_test.py index 3909b80568..aa572cffb6 100644 --- a/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals_test.py +++ b/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_no_subscriptions(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_no_policies(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -61,6 +61,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -105,6 +106,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -149,6 +151,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_mfa_disabled(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -193,6 +196,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_mfa_no_target(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -237,6 +241,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: def test_entra_tenant_policy_mfa_no_users(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api_test.py b/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api_test.py index 3c880886ee..82362135a9 100644 --- a/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api_test.py +++ b/tests/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_no_subscriptions(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_no_policies(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -61,6 +61,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -105,6 +106,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -149,6 +151,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_mfa_disabled(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -193,6 +196,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_mfa_no_target(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( @@ -237,6 +241,7 @@ class Test_entra_conditional_access_policy_require_mfa_for_management_api: def test_entra_tenant_policy_mfa_no_users(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} policy_id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py b/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py index 4820f13ad9..4270f485f3 100644 --- a/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py +++ b/tests/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_global_admin_in_less_than_five_users: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -32,7 +32,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_tenant_empty(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -57,7 +57,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_less_than_five_global_admins(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -110,7 +110,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_more_than_five_global_admins(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -178,7 +178,7 @@ class Test_entra_global_admin_in_less_than_five_users: def test_entra_exactly_five_global_admins(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py index 4d2f289a90..04d838a2c0 100644 --- a/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_non_privileged_user_has_mfa: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_tenant_no_users(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -53,6 +53,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_no_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -100,6 +101,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_no_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -144,6 +146,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_disabled_user_no_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -184,6 +187,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_disabled_user_no_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -224,6 +228,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -265,6 +270,7 @@ class Test_entra_non_privileged_user_has_mfa: def test_entra_user_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups_test.py b/tests/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups_test.py index 603fae5863..df614a06e4 100644 --- a/tests/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups_test.py +++ b/tests/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups_test.py @@ -7,6 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_default_users_cannot_create_security_groups: def test_entra_no_tenants(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.authorization_policy = {} with ( @@ -29,6 +30,7 @@ class Test_entra_policy_default_users_cannot_create_security_groups: def test_entra_tenant_empty(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -75,6 +77,7 @@ class Test_entra_policy_default_users_cannot_create_security_groups: self, ): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -124,6 +127,7 @@ class Test_entra_policy_default_users_cannot_create_security_groups: self, ): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps_test.py b/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps_test.py index d62941388c..5bfa9b2b4b 100644 --- a/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps_test.py +++ b/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_ensure_default_user_cannot_create_apps: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,6 +30,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_apps: def test_entra_tenant_empty(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -75,7 +76,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_apps: def test_entra_default_user_role_permissions_not_allowed_to_create_apps(self): id = str(uuid4()) entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -122,7 +123,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_apps: def test_entra_default_user_role_permissions_allowed_to_create_apps(self): id = str(uuid4()) entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants_test.py b/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants_test.py index b9a678bc08..391c3f424f 100644 --- a/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants_test.py +++ b/tests/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants_test.py @@ -7,6 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_ensure_default_user_cannot_create_tenants: def test_entra_no_tenants(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.authorization_policy = {} with ( @@ -29,6 +30,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_tenants: def test_entra_empty_tenant(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -74,7 +76,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_tenants: def test_entra_default_user_role_permissions_not_allowed_to_create_tenants(self): id = str(uuid4()) entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -121,7 +123,7 @@ class Test_entra_policy_ensure_default_user_cannot_create_tenants: def test_entra_default_user_role_permissions_allowed_to_create_tenants(self): id = str(uuid4()) entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles_test.py b/tests/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles_test.py index a59c84b6b3..e844b900f1 100644 --- a/tests/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles_test.py +++ b/tests/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,6 +30,7 @@ class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_empty_tenant(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -76,6 +77,7 @@ class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_tenant_policy_allow_invites_from_everyone(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -120,6 +122,7 @@ class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_tenant_policy_allow_invites_from_admins(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -164,6 +167,7 @@ class Test_entra_policy_guest_invite_only_for_admin_roles: def test_entra_tenant_policy_allow_invites_from_none(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions_test.py b/tests/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions_test.py index 4f70895846..9b7aecf053 100644 --- a/tests/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions_test.py +++ b/tests/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_guest_users_access_restrictions: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,6 +30,7 @@ class Test_entra_policy_guest_users_access_restrictions: def test_entra_tenant_empty(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -74,6 +75,7 @@ class Test_entra_policy_guest_users_access_restrictions: def test_entra_tenant_policy_access_same_as_member(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -117,6 +119,7 @@ class Test_entra_policy_guest_users_access_restrictions: def test_entra_tenant_policy_limited_access(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -160,6 +163,7 @@ class Test_entra_policy_guest_users_access_restrictions: def test_entra_tenant_policy_access_restricted(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps_test.py b/tests/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps_test.py index 36a03cab1d..bf4c43b2c2 100644 --- a/tests/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps_test.py +++ b/tests/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,6 +30,7 @@ class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_tenant_empty(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} id = str(uuid4()) with ( @@ -74,7 +75,7 @@ class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_tenant_no_default_user_role_permissions(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -116,7 +117,7 @@ class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_tenant_no_consent(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -162,7 +163,7 @@ class Test_entra_policy_restricts_user_consent_for_apps: def test_entra_tenant_legacy_consent(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps_test.py b/tests/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps_test.py index 02bd0a2220..74dc98fd2a 100644 --- a/tests/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps_test.py +++ b/tests/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_policy_user_consent_for_verified_apps: def test_entra_no_subscriptions(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_policy_user_consent_for_verified_apps: def test_entra_tenant_no_consent(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -76,7 +76,7 @@ class Test_entra_policy_user_consent_for_verified_apps: def test_entra_tenant_legacy_consent(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py b/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py index 31e0a57bff..3475baf592 100644 --- a/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_privileged_user_has_mfa: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_tenant_no_users(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -53,6 +53,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_no_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -92,6 +93,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_no_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -131,6 +133,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_privileged_no_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( @@ -177,6 +180,7 @@ class Test_entra_privileged_user_has_mfa: def test_entra_user_privileged_mfa(self): entra_client = mock.MagicMock + entra_client.resource_groups = {} user_id = str(uuid4()) with ( diff --git a/tests/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled_test.py b/tests/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled_test.py index 562c008c52..11d6d8ff8e 100644 --- a/tests/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled_test.py +++ b/tests/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled_test.py @@ -7,7 +7,7 @@ from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provid class Test_entra_security_defaults_enabled: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -30,7 +30,7 @@ class Test_entra_security_defaults_enabled: def test_entra_tenant_empty(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -58,7 +58,7 @@ class Test_entra_security_defaults_enabled: def test_entra_security_default_enabled(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -93,7 +93,7 @@ class Test_entra_security_defaults_enabled: def test_entra_security_default_disabled(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists_test.py b/tests/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists_test.py index 2af5c975cb..89a8ba7f07 100644 --- a/tests/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists_test.py +++ b/tests/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists_test.py @@ -10,7 +10,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_entra_trusted_named_locations_exists: def test_entra_no_tenants(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -34,7 +34,7 @@ class Test_entra_trusted_named_locations_exists: def test_entra_tenant_empty(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -67,7 +67,7 @@ class Test_entra_trusted_named_locations_exists: def test_entra_named_location_with_ip_ranges(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -111,7 +111,7 @@ class Test_entra_trusted_named_locations_exists: def test_entra_named_location_without_ip_ranges(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -156,7 +156,7 @@ class Test_entra_trusted_named_locations_exists: def test_entra_new_named_location_with_ip_ranges_not_trusted(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py index 46dc9389af..83c06ea5b6 100644 --- a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py @@ -14,10 +14,11 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_iam_no_roles(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} - with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -41,9 +42,11 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -112,9 +115,11 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa_no_mfa(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -183,9 +188,11 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa_no_user(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) @@ -237,9 +244,11 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: def test_entra_user_with_vm_access_has_mfa_no_role(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_assigment_id = str(uuid4()) entra_client = mock.MagicMock + entra_client.resource_groups = {} entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} user_id = str(uuid4()) diff --git a/tests/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups_test.py b/tests/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups_test.py index ee82e9a07a..eb7269f6f2 100644 --- a/tests/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups_test.py +++ b/tests/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups_test.py @@ -11,7 +11,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_no_tenant(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -35,7 +35,7 @@ class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_tenant_empty(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -65,7 +65,7 @@ class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_users_cannot_create_microsoft_365_groups(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -114,7 +114,7 @@ class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_users_can_create_microsoft_365_groups(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -161,7 +161,7 @@ class Test_entra_users_cannot_create_microsoft_365_groups: def test_entra_users_can_create_microsoft_365_groups_no_setting(self): entra_client = mock.MagicMock - + entra_client.resource_groups = {} with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", diff --git a/tests/providers/azure/services/iam/azure_iam_service_test.py b/tests/providers/azure/services/iam/azure_iam_service_test.py new file mode 100644 index 0000000000..3f1dfec6fc --- /dev/null +++ b/tests/providers/azure/services/iam/azure_iam_service_test.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.azure.services.iam.iam_service import IAM +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + + +class Test_IAM_get_roles: + def test_get_roles_no_resource_groups(self): + mock_client = MagicMock() + mock_client.role_definitions.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = None + + builtin, custom = iam._get_roles() + + mock_client.role_definitions.list.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in builtin + assert AZURE_SUBSCRIPTION_ID in custom + + def test_get_roles_with_resource_group(self): + mock_client = MagicMock() + mock_client.role_definitions.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + builtin, custom = iam._get_roles() + + mock_client.role_definitions.list.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in builtin + assert AZURE_SUBSCRIPTION_ID in custom + + def test_get_roles_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.role_definitions.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + builtin, custom = iam._get_roles() + + mock_client.role_definitions.list.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in builtin + assert AZURE_SUBSCRIPTION_ID in custom + + +class Test_IAM_get_role_assignments: + def test_get_role_assignments_no_resource_groups(self): + mock_client = MagicMock() + mock_client.role_assignments = MagicMock() + mock_client.role_assignments.list_for_subscription.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = None + + result = iam._get_role_assignments() + + mock_client.role_assignments.list_for_subscription.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_role_assignments_with_resource_group(self): + mock_client = MagicMock() + mock_client.role_assignments = MagicMock() + mock_client.role_assignments.list_for_subscription.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = iam._get_role_assignments() + + mock_client.role_assignments.list_for_subscription.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_role_assignments_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.role_assignments = MagicMock() + mock_client.role_assignments.list_for_subscription.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = iam._get_role_assignments() + + mock_client.role_assignments.list_for_subscription.assert_called_once() + assert AZURE_SUBSCRIPTION_ID in result diff --git a/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py b/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py index 5125130871..2d808c7102 100644 --- a/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py +++ b/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py @@ -14,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_custom_role_has_permissions_to_administer_resource_locks: def test_iam_no_roles(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.custom_roles = {} @@ -39,6 +40,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: self, ): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { @@ -95,6 +97,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: self, ): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { @@ -144,6 +147,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: self, ): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" role_name2 = "test-role2" @@ -212,6 +216,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: def test_iam_custom_roles_empty_list_but_with_key(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.custom_roles = {AZURE_SUBSCRIPTION_ID: {}} diff --git a/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py b/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py index 8ccf6e6f64..3ead279d6b 100644 --- a/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py +++ b/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py @@ -13,6 +13,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_role_user_access_admin_restricted: def test_iam_no_role_assignments(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} iam_client.role_assignments = {} iam_client.roles = {} @@ -37,6 +38,7 @@ class Test_iam_role_user_access_admin_restricted: def test_iam_user_access_administrator_role_assigned(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} role_id = str(uuid4()) role_assignment_id = str(uuid4()) agent_id = str(uuid4()) @@ -97,6 +99,7 @@ class Test_iam_role_user_access_admin_restricted: def test_iam_non_user_access_administrator_role_assigned(self): iam_client = mock.MagicMock + iam_client.resource_groups = {} role_id = str(uuid4()) role_assignment_id = str(uuid4()) agent_id = str(uuid4()) diff --git a/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py b/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py index 1d2d37ee11..2687cb75a9 100644 --- a/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py +++ b/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py @@ -14,6 +14,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_iam_subscription_roles_owner_custom_not_created: def test_iam_no_roles(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} defender_client.custom_roles = {} @@ -37,6 +38,7 @@ class Test_iam_subscription_roles_owner_custom_not_created: def test_iam_custom_owner_role_created_with_all(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { @@ -84,6 +86,7 @@ class Test_iam_subscription_roles_owner_custom_not_created: def test_iam_custom_owner_role_created_with_no_permissions(self): defender_client = mock.MagicMock + defender_client.resource_groups = {} defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} role_name = "test-role" defender_client.custom_roles = { diff --git a/tests/providers/azure/services/keyvault/keyvault_service_test.py b/tests/providers/azure/services/keyvault/keyvault_service_test.py index f0a73d9081..e43b7a9fff 100644 --- a/tests/providers/azure/services/keyvault/keyvault_service_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_service_test.py @@ -3,6 +3,8 @@ from unittest.mock import MagicMock, patch from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -263,3 +265,208 @@ class Test_keyvault_service: .storage_account_name == "storage_account_name" ) + + +class Test_KeyVault_get_key_vaults: + def test_get_key_vaults_no_resource_groups(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_subscription.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.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = None + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + result = keyvault._get_key_vaults(provider) + + mock_client.vaults.list_by_subscription.assert_called_once() + mock_client.vaults.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_key_vaults_with_resource_group(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.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.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + result = keyvault._get_key_vaults(provider) + + mock_client.vaults.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.vaults.list_by_subscription.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_key_vaults_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.vaults = 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.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + result = keyvault._get_key_vaults(provider) + + mock_client.vaults.list_by_resource_group.assert_not_called() + mock_client.vaults.list_by_subscription.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_key_vaults_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.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.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + result = keyvault._get_key_vaults(provider) + + assert mock_client.vaults.list_by_resource_group.call_count == len( + RESOURCE_GROUP_LIST + ) + mock_client.vaults.list_by_subscription.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_key_vaults_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.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.keyvault.keyvault_service.KeyVault._get_key_vaults", + return_value={}, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVault, + ) + + keyvault = KeyVault(set_mocked_azure_provider()) + + keyvault.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + keyvault.resource_groups = {AZURE_SUBSCRIPTION_ID: ["MyRG"]} + + provider = set_mocked_azure_provider() + with patch( + "prowler.providers.azure.services.keyvault.keyvault_service.monitor_client" + ): + keyvault._get_key_vaults(provider) + + mock_client.vaults.list_by_resource_group.assert_called_once_with( + resource_group_name="MyRG" + ) diff --git a/tests/providers/azure/services/mysql/mysql_service_test.py b/tests/providers/azure/services/mysql/mysql_service_test.py index 24364f175a..728e50610b 100644 --- a/tests/providers/azure/services/mysql/mysql_service_test.py +++ b/tests/providers/azure/services/mysql/mysql_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.mysql.mysql_service import ( Configuration, @@ -7,6 +7,8 @@ from prowler.providers.azure.services.mysql.mysql_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -117,3 +119,131 @@ class Test_MySQL_Service: assert configurations["test"].resource_id == "/subscriptions/resource_id" assert configurations["test"].description == "description" assert configurations["test"].value == "value" + + +class Test_MySQL_get_flexible_servers: + def test_get_flexible_servers_no_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = None + + result = mysql._get_flexible_servers() + + mock_client.servers.list.assert_called_once() + mock_client.servers.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_with_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = mysql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.servers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = mysql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_not_called() + mock_client.servers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_flexible_servers_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = mysql._get_flexible_servers() + + assert mock_client.servers.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_flexible_servers", + return_value={}, + ), + patch( + "prowler.providers.azure.services.mysql.mysql_service.MySQL._get_configurations", + return_value={}, + ), + ): + mysql = MySQL(set_mocked_azure_provider()) + + mysql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + mysql.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + mysql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/network/network_service_test.py b/tests/providers/azure/services/network/network_service_test.py index 8a0e72542f..9a440b90cc 100644 --- a/tests/providers/azure/services/network/network_service_test.py +++ b/tests/providers/azure/services/network/network_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from azure.mgmt.network.models import FlowLog @@ -8,9 +8,12 @@ from prowler.providers.azure.services.network.network_service import ( NetworkWatcher, PublicIp, SecurityGroup, + VirtualNetwork, ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -66,6 +69,20 @@ def mock_network_get_public_ip_addresses(_): } +def mock_network_get_virtual_networks(_): + return { + AZURE_SUBSCRIPTION_ID: [ + VirtualNetwork( + id="id", + name="name", + location="location", + enable_ddos_protection=False, + subnets=[], + ) + ] + } + + @patch( "prowler.providers.azure.services.network.network_service.Network._get_security_groups", new=mock_network_get_security_groups, @@ -82,6 +99,10 @@ def mock_network_get_public_ip_addresses(_): "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", new=mock_network_get_public_ip_addresses, ) +@patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, +) class Test_Network_Service: def test_get_client(self): network = Network(set_mocked_azure_provider()) @@ -162,3 +183,905 @@ class Test_Network_Service: network.public_ip_addresses[AZURE_SUBSCRIPTION_ID][0].ip_address == "ip_address" ) + + +class Test_Network_get_security_groups: + def test_get_security_groups_no_resource_groups(self): + mock_client = MagicMock() + mock_client.network_security_groups.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_security_groups() + + mock_client.network_security_groups.list_all.assert_called_once() + mock_client.network_security_groups.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_security_groups_with_resource_group(self): + mock_client = MagicMock() + mock_client.network_security_groups.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_security_groups() + + mock_client.network_security_groups.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.network_security_groups.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_security_groups_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_security_groups() + + mock_client.network_security_groups.list.assert_not_called() + mock_client.network_security_groups.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + +class Test_Network_get_network_watchers: + def test_get_network_watchers_no_resource_groups(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_network_watchers_with_resource_group(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_network_watchers_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + +class Test_Network_get_bastion_hosts: + def test_get_bastion_hosts_no_resource_groups(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + mock_client.bastion_hosts.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_bastion_hosts() + + mock_client.bastion_hosts.list.assert_called_once() + mock_client.bastion_hosts.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_bastion_hosts_with_resource_group(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + mock_client.bastion_hosts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_bastion_hosts() + + mock_client.bastion_hosts.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.bastion_hosts.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_bastion_hosts_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_bastion_hosts() + + mock_client.bastion_hosts.list_by_resource_group.assert_not_called() + mock_client.bastion_hosts.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + +class Test_Network_get_public_ip_addresses: + def test_get_public_ip_addresses_no_resource_groups(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + mock_client.public_ip_addresses.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_public_ip_addresses() + + mock_client.public_ip_addresses.list_all.assert_called_once() + mock_client.public_ip_addresses.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_public_ip_addresses_with_resource_group(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + mock_client.public_ip_addresses.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_public_ip_addresses() + + mock_client.public_ip_addresses.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.public_ip_addresses.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_public_ip_addresses_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_public_ip_addresses() + + mock_client.public_ip_addresses.list.assert_not_called() + mock_client.public_ip_addresses.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_security_groups_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.network_security_groups = MagicMock() + mock_client.network_security_groups.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_security_groups() + + assert mock_client.network_security_groups.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_security_groups_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.network_security_groups = MagicMock() + mock_client.network_security_groups.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_security_groups() + + mock_client.network_security_groups.list.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_Network_get_network_watchers_extra: + def test_get_network_watchers_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_network_watchers_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.network_watchers = MagicMock() + mock_client.network_watchers.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_network_watchers() + + mock_client.network_watchers.list_all.assert_called_once() + mock_client.network_watchers.list.assert_not_called() + + +class Test_Network_get_bastion_hosts_extra: + def test_get_bastion_hosts_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + mock_client.bastion_hosts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_bastion_hosts() + + assert mock_client.bastion_hosts.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_bastion_hosts_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.bastion_hosts = MagicMock() + mock_client.bastion_hosts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_bastion_hosts() + + mock_client.bastion_hosts.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_Network_get_public_ip_addresses_extra: + def test_get_public_ip_addresses_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + mock_client.public_ip_addresses.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_public_ip_addresses() + + assert mock_client.public_ip_addresses.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_public_ip_addresses_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.public_ip_addresses = MagicMock() + mock_client.public_ip_addresses.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_public_ip_addresses() + + mock_client.public_ip_addresses.list.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_Network_get_virtual_networks_extra: + def _ctx(self): + return ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + ) + + def test_get_virtual_networks_no_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + mock_client.virtual_networks.list_all.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = None + + result = network._get_virtual_networks() + + mock_client.virtual_networks.list_all.assert_called_once() + mock_client.virtual_networks.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_networks_with_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + mock_client.virtual_networks.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = network._get_virtual_networks() + + mock_client.virtual_networks.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.virtual_networks.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_networks_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = network._get_virtual_networks() + + mock_client.virtual_networks.list.assert_not_called() + mock_client.virtual_networks.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_virtual_networks_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + mock_client.virtual_networks.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = network._get_virtual_networks() + + assert mock_client.virtual_networks.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_networks_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_networks = MagicMock() + mock_client.virtual_networks.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.network.network_service.Network._get_security_groups", + new=mock_network_get_security_groups, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_bastion_hosts", + new=mock_network_get_bastion_hosts, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_network_watchers", + new=mock_network_get_network_watchers, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_public_ip_addresses", + new=mock_network_get_public_ip_addresses, + ), + patch( + "prowler.providers.azure.services.network.network_service.Network._get_virtual_networks", + new=mock_network_get_virtual_networks, + ), + ): + network = Network(set_mocked_azure_provider()) + + network.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + network.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + network._get_virtual_networks() + + mock_client.virtual_networks.list.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/policy/policy_service_test.py b/tests/providers/azure/services/policy/policy_service_test.py index 381ab82466..5a983d8610 100644 --- a/tests/providers/azure/services/policy/policy_service_test.py +++ b/tests/providers/azure/services/policy/policy_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.policy.policy_service import ( Policy, @@ -6,6 +6,8 @@ from prowler.providers.azure.services.policy.policy_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -52,3 +54,99 @@ class Test_Policy_Service: policy.policy_assigments[AZURE_SUBSCRIPTION_ID]["policy-1"].enforcement_mode == "Default" ) + + +class Test_Policy_get_policy_assigments: + def test_get_policy_assigments_no_resource_groups(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = None + + result = policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_policy_assigments_with_resource_group(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_policy_assigments_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_policy_assigments_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_policy_assigments_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.policy_assignments.list.return_value = [] + + with patch( + "prowler.providers.azure.services.policy.policy_service.Policy._get_policy_assigments", + return_value={}, + ): + policy = Policy(set_mocked_azure_provider()) + + policy.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + policy.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + policy._get_policy_assigments() + + mock_client.policy_assignments.list.assert_called_once() + mock_client.policy_assignments.list_for_resource_group.assert_not_called() diff --git a/tests/providers/azure/services/postgresql/postgresql_service_test.py b/tests/providers/azure/services/postgresql/postgresql_service_test.py index f372de8844..c9fea2b307 100644 --- a/tests/providers/azure/services/postgresql/postgresql_service_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_service_test.py @@ -11,6 +11,8 @@ from prowler.providers.azure.services.postgresql.postgresql_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -243,6 +245,103 @@ class Test_SqlServer_Service: ) +class Test_PostgreSQL_get_flexible_servers: + def test_get_flexible_servers_no_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list.return_value = [] + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = None + + result = postgresql._get_flexible_servers() + + mock_client.servers.list.assert_called_once() + mock_client.servers.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_with_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = postgresql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.servers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = postgresql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_not_called() + mock_client.servers.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_flexible_servers_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = postgresql._get_flexible_servers() + + assert mock_client.servers.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_flexible_servers_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.PostgreSQL._get_flexible_servers", + return_value={}, + ): + postgresql = PostgreSQL(set_mocked_azure_provider()) + + postgresql.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + postgresql.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + postgresql._get_flexible_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + def _make_server(name): server = MagicMock() server.id = ( diff --git a/tests/providers/azure/services/recovery/__init__.py b/tests/providers/azure/services/recovery/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/azure/services/recovery/recovery_service_test.py b/tests/providers/azure/services/recovery/recovery_service_test.py index 93dcad1e38..96c358b7d2 100644 --- a/tests/providers/azure/services/recovery/recovery_service_test.py +++ b/tests/providers/azure/services/recovery/recovery_service_test.py @@ -1,11 +1,18 @@ from types import SimpleNamespace from unittest import mock +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.recovery.recovery_service import ( BackupVault, + Recovery, RecoveryBackup, ) -from tests.providers.azure.azure_fixtures import AZURE_SUBSCRIPTION_ID +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, + set_mocked_azure_provider, +) VAULT_ID = ( f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/" @@ -20,6 +27,139 @@ class BackupClientFake: self.backup_policies.list.return_value = policies +class Test_Recovery_get_vaults: + def test_get_vaults_no_resource_groups(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_subscription_id.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = None + + result = recovery._get_vaults() + + mock_client.vaults.list_by_subscription_id.assert_called_once() + mock_client.vaults.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vaults_with_resource_group(self): + mock_vault = MagicMock() + mock_vault.id = "vault-id-1" + mock_vault.name = "my-vault" + mock_vault.location = "eastus" + + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_resource_group.return_value = [mock_vault] + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = recovery._get_vaults() + + mock_client.vaults.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.vaults.list_by_subscription_id.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + assert "vault-id-1" in result[AZURE_SUBSCRIPTION_ID] + + def test_get_vaults_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = recovery._get_vaults() + + mock_client.vaults.list_by_resource_group.assert_not_called() + mock_client.vaults.list_by_subscription_id.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_vaults_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = recovery._get_vaults() + + assert mock_client.vaults.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vaults_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.vaults = MagicMock() + mock_client.vaults.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.recovery.recovery_service.Recovery._get_vaults", + return_value={}, + ), + patch( + "prowler.providers.azure.services.recovery.recovery_service.RecoveryBackup", + ), + ): + recovery = Recovery(set_mocked_azure_provider()) + + recovery.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + recovery.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + recovery._get_vaults() + + mock_client.vaults.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + class Test_RecoveryBackup_Service: def test_get_backup_policies_lists_unprotected_vault_policies(self): policy = SimpleNamespace( diff --git a/tests/providers/azure/services/sqlserver/sqlserver_service_test.py b/tests/providers/azure/services/sqlserver/sqlserver_service_test.py index 4fc4f073fe..7da2fe8b58 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_service_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from azure.mgmt.sql.models import ( EncryptionProtector, @@ -16,6 +16,8 @@ from prowler.providers.azure.services.sqlserver.sqlserver_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -245,3 +247,100 @@ class Test_SqlServer_Service: ].security_alert_policies.state == "Disabled" ) + + +class Test_SQLServer_get_sql_servers: + def test_get_sql_servers_no_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list.return_value = [] + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = None + + result = sql_server._get_sql_servers() + + mock_client.servers.list.assert_called_once() + mock_client.servers.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_sql_servers_with_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = sql_server._get_sql_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.servers.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_sql_servers_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = sql_server._get_sql_servers() + + mock_client.servers.list_by_resource_group.assert_not_called() + mock_client.servers.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_sql_servers_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = sql_server._get_sql_servers() + + assert mock_client.servers.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_sql_servers_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.servers.list_by_resource_group.return_value = [] + + with patch( + "prowler.providers.azure.services.sqlserver.sqlserver_service.SQLServer._get_sql_servers", + return_value={}, + ): + sql_server = SQLServer(set_mocked_azure_provider()) + + sql_server.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + sql_server.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + sql_server._get_sql_servers() + + mock_client.servers.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/storage/storage_service_test.py b/tests/providers/azure/services/storage/storage_service_test.py index 67fba33877..563b1b6a21 100644 --- a/tests/providers/azure/services/storage/storage_service_test.py +++ b/tests/providers/azure/services/storage/storage_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.storage.storage_service import ( Account, @@ -11,6 +11,8 @@ from prowler.providers.azure.services.storage.storage_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -387,3 +389,155 @@ class Test_Storage_Service_Retention_Policy_None_Handling: is False ) assert account.file_service_properties.share_delete_retention_policy.days == 0 + + +class Test_Storage_get_storage_accounts: + def test_get_storage_accounts_no_resource_groups(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + mock_client.storage_accounts.list.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = None + + result = storage._get_storage_accounts() + + mock_client.storage_accounts.list.assert_called_once() + mock_client.storage_accounts.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_storage_accounts_with_resource_group(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + mock_client.storage_accounts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = storage._get_storage_accounts() + + mock_client.storage_accounts.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.storage_accounts.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_storage_accounts_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = storage._get_storage_accounts() + + mock_client.storage_accounts.list_by_resource_group.assert_not_called() + mock_client.storage_accounts.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == [] + + def test_get_storage_accounts_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + mock_client.storage_accounts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = storage._get_storage_accounts() + + assert mock_client.storage_accounts.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_storage_accounts_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.storage_accounts = MagicMock() + mock_client.storage_accounts.list_by_resource_group.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_storage_accounts", + return_value={}, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_blob_properties", + return_value=None, + ), + patch( + "prowler.providers.azure.services.storage.storage_service.Storage._get_file_share_properties", + return_value=None, + ), + ): + storage = Storage(set_mocked_azure_provider()) + + storage.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + storage.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + storage._get_storage_accounts() + + mock_client.storage_accounts.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/azure/services/vm/vm_service_test.py b/tests/providers/azure/services/vm/vm_service_test.py index 49b8045cf8..e25c6ffce3 100644 --- a/tests/providers/azure/services/vm/vm_service_test.py +++ b/tests/providers/azure/services/vm/vm_service_test.py @@ -14,6 +14,8 @@ from prowler.providers.azure.services.vm.vm_service import ( ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, set_mocked_azure_provider, ) @@ -465,3 +467,328 @@ class Test_VirtualMachine_SecurityProfile_Validation: assert isinstance(vm.security_profile.uefi_settings, UefiSettings) assert vm.security_profile.uefi_settings.secure_boot_enabled is True assert vm.security_profile.uefi_settings.v_tpm_enabled is True + + +class Test_VM_get_virtual_machines: + def test_get_virtual_machines_no_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + mock_client.virtual_machines.list_all.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = None + + result = vm_service._get_virtual_machines() + + mock_client.virtual_machines.list_all.assert_called_once() + mock_client.virtual_machines.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_machines_with_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + mock_client.virtual_machines.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = vm_service._get_virtual_machines() + + mock_client.virtual_machines.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.virtual_machines.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_machines_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = vm_service._get_virtual_machines() + + mock_client.virtual_machines.list.assert_not_called() + mock_client.virtual_machines.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + +class Test_VM_get_disks: + def test_get_disks_no_resource_groups(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + mock_client.disks.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = None + + result = vm_service._get_disks() + + mock_client.disks.list.assert_called_once() + mock_client.disks.list_by_resource_group.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_disks_with_resource_group(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + mock_client.disks.list_by_resource_group.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = vm_service._get_disks() + + mock_client.disks.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.disks.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_disks_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = vm_service._get_disks() + + mock_client.disks.list_by_resource_group.assert_not_called() + mock_client.disks.list.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + +class Test_VM_get_vm_scale_sets: + def test_get_vm_scale_sets_no_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + mock_client.virtual_machine_scale_sets.list_all.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = None + + result = vm_service._get_vm_scale_sets() + + mock_client.virtual_machine_scale_sets.list_all.assert_called_once() + mock_client.virtual_machine_scale_sets.list.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vm_scale_sets_with_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + mock_client.virtual_machine_scale_sets.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + + result = vm_service._get_vm_scale_sets() + + mock_client.virtual_machine_scale_sets.list.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.virtual_machine_scale_sets.list_all.assert_not_called() + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vm_scale_sets_empty_resource_group_for_subscription(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: []} + + result = vm_service._get_vm_scale_sets() + + mock_client.virtual_machine_scale_sets.list.assert_not_called() + mock_client.virtual_machine_scale_sets.list_all.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_get_virtual_machines_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + mock_client.virtual_machines.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = vm_service._get_virtual_machines() + + assert mock_client.virtual_machines.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_virtual_machines_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_machines = MagicMock() + mock_client.virtual_machines.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + vm_service._get_virtual_machines() + + mock_client.virtual_machines.list.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_VM_get_disks_extra: + def test_get_disks_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + mock_client.disks.list_by_resource_group.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = vm_service._get_disks() + + assert mock_client.disks.list_by_resource_group.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_disks_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.disks = MagicMock() + mock_client.disks.list_by_resource_group.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + vm_service._get_disks() + + mock_client.disks.list_by_resource_group.assert_called_once_with( + resource_group_name="RG" + ) + + +class Test_VM_get_vm_scale_sets_extra: + def test_get_vm_scale_sets_with_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + mock_client.virtual_machine_scale_sets.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + + result = vm_service._get_vm_scale_sets() + + assert mock_client.virtual_machine_scale_sets.list.call_count == 2 + assert AZURE_SUBSCRIPTION_ID in result + + def test_get_vm_scale_sets_with_mixed_case_resource_group(self): + mock_client = MagicMock() + mock_client.virtual_machine_scale_sets = MagicMock() + mock_client.virtual_machine_scale_sets.list.return_value = [] + + with ( + patch.object(VirtualMachines, "_get_virtual_machines", return_value={}), + patch.object(VirtualMachines, "_get_disks", return_value={}), + patch.object(VirtualMachines, "_get_vm_scale_sets", return_value={}), + ): + vm_service = VirtualMachines(set_mocked_azure_provider()) + + vm_service.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + vm_service.resource_groups = {AZURE_SUBSCRIPTION_ID: ["RG"]} + + vm_service._get_vm_scale_sets() + + mock_client.virtual_machine_scale_sets.list.assert_called_once_with( + resource_group_name="RG" + ) diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index ba6247c7bd..fb8a5e5e82 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -1,40 +1,41 @@ import asyncio +import importlib from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from prowler.providers.m365.models import M365IdentityInfo -from prowler.providers.m365.services.entra.entra_service import ( - AdminConsentPolicy, - AdminRoles, - ApplicationEnforcedRestrictions, - ApplicationsConditions, - AppManagementRestrictions, - AuthorizationPolicy, - AuthPolicyRoles, - ConditionalAccessGrantControl, - ConditionalAccessPolicy, - ConditionalAccessPolicyState, - Conditions, - CredentialRestriction, - DefaultAppManagementPolicy, - DefaultUserRolePermissions, - Entra, - GrantControlOperator, - GrantControls, - InvitationsFrom, - Organization, - PersistentBrowser, - SessionControls, - SignInFrequency, - SignInFrequencyInterval, - SignInFrequencyType, - User, - UserAction, - UsersConditions, -) +from prowler.providers.m365.services.entra import entra_service from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider +AdminConsentPolicy = entra_service.AdminConsentPolicy +AdminRoles = entra_service.AdminRoles +ApplicationEnforcedRestrictions = entra_service.ApplicationEnforcedRestrictions +ApplicationsConditions = entra_service.ApplicationsConditions +AppManagementRestrictions = entra_service.AppManagementRestrictions +AuthorizationPolicy = entra_service.AuthorizationPolicy +AuthPolicyRoles = entra_service.AuthPolicyRoles +ConditionalAccessGrantControl = entra_service.ConditionalAccessGrantControl +ConditionalAccessPolicy = entra_service.ConditionalAccessPolicy +ConditionalAccessPolicyState = entra_service.ConditionalAccessPolicyState +Conditions = entra_service.Conditions +CredentialRestriction = entra_service.CredentialRestriction +DefaultAppManagementPolicy = entra_service.DefaultAppManagementPolicy +DefaultUserRolePermissions = entra_service.DefaultUserRolePermissions +Entra = entra_service.Entra +GrantControlOperator = entra_service.GrantControlOperator +GrantControls = entra_service.GrantControls +InvitationsFrom = entra_service.InvitationsFrom +Organization = entra_service.Organization +PersistentBrowser = entra_service.PersistentBrowser +SessionControls = entra_service.SessionControls +SignInFrequency = entra_service.SignInFrequency +SignInFrequencyInterval = entra_service.SignInFrequencyInterval +SignInFrequencyType = entra_service.SignInFrequencyType +User = entra_service.User +UserAction = entra_service.UserAction +UsersConditions = entra_service.UsersConditions + async def mock_entra_get_authorization_policy(_): return AuthorizationPolicy( @@ -697,9 +698,12 @@ class Test_Entra_Service: a descriptive error message naming the missing AuditLog.Read.All permission. """ from msgraph.generated.models.o_data_errors.main_error import MainError - from msgraph.generated.models.o_data_errors.o_data_error import ODataError - odata_error = ODataError() + o_data_error = importlib.import_module( + "msgraph.generated.models.o_data_errors.o_data_error" + ) + + odata_error = o_data_error.ODataError() odata_error.error = MainError() odata_error.error.code = "Authorization_RequestDenied" @@ -879,6 +883,134 @@ class Test_Entra_Service: assert merged.password_credentials[0].display_name == "app-level-secret" assert merged.password_credentials[0].is_active() + def test__get_exchange_mailbox_permission_service_principals(self): + """Service principals with Exchange Graph application roles are returned.""" + graph_sp_id = "graph-sp-id" + mail_read_role_id = "11111111-1111-1111-1111-111111111111" + user_read_role_id = "22222222-2222-2222-2222-222222222222" + + graph_sp = SimpleNamespace( + id=graph_sp_id, + display_name="Microsoft Graph", + app_id="00000003-0000-0000-c000-000000000000", + app_owner_organization_id="f8cdef31-a31e-4b4a-93e4-5f571e91255a", + app_roles=[ + SimpleNamespace( + id=mail_read_role_id, + value="Mail.Read", + allowed_member_types=["Application"], + ), + SimpleNamespace( + id=user_read_role_id, + value="User.Read.All", + allowed_member_types=["Application"], + ), + ], + account_enabled=True, + service_principal_type="Application", + ) + mailbox_app = SimpleNamespace( + id="sp-mailbox", + display_name="Mailbox App", + app_id="app-mailbox", + app_owner_organization_id="33333333-3333-3333-3333-333333333333", + app_roles=[], + account_enabled=True, + service_principal_type="Application", + ) + disabled_app = SimpleNamespace( + id="sp-disabled", + display_name="Disabled App", + app_id="app-disabled", + app_owner_organization_id="33333333-3333-3333-3333-333333333333", + app_roles=[], + account_enabled=False, + service_principal_type="Application", + ) + first_party_app = SimpleNamespace( + id="sp-first-party", + display_name="Microsoft App", + app_id="app-first-party", + app_owner_organization_id="f8cdef31-a31e-4b4a-93e4-5f571e91255a", + app_roles=[], + account_enabled=True, + service_principal_type="Application", + ) + + app_role_assignments = { + "sp-mailbox": SimpleNamespace( + value=[ + SimpleNamespace( + resource_id=graph_sp_id, + app_role_id=mail_read_role_id, + ), + SimpleNamespace( + resource_id=graph_sp_id, + app_role_id=user_read_role_id, + ), + ], + odata_next_link=None, + ) + } + + def by_service_principal_id(service_principal_id): + return SimpleNamespace( + app_role_assignments=SimpleNamespace( + get=AsyncMock( + return_value=app_role_assignments.get( + service_principal_id, + SimpleNamespace(value=[], odata_next_link=None), + ) + ), + with_url=MagicMock(), + ) + ) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + service_principals=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + value=[graph_sp, mailbox_app, disabled_app, first_party_app], + odata_next_link=None, + ) + ), + with_url=MagicMock(), + by_service_principal_id=MagicMock(side_effect=by_service_principal_id), + ) + ) + + result = asyncio.run( + entra_service._get_exchange_mailbox_permission_service_principals() + ) + + assert set(result.keys()) == {"sp-mailbox"} + assert result["sp-mailbox"].app_id == "app-mailbox" + assert result["sp-mailbox"].exchange_mailbox_permissions == ["Mail.Read"] + + def test__get_exchange_mailbox_permission_service_principals_records_error(self): + """ + Graph collection failures preserve unavailable state separately from empty results. + """ + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + service_principals=SimpleNamespace( + get=AsyncMock(side_effect=RuntimeError("Graph unavailable")) + ) + ) + + result = asyncio.run( + entra_service._get_exchange_mailbox_permission_service_principals() + ) + + assert result == {} + assert "RuntimeError" in ( + entra_service.exchange_mailbox_permission_service_principals_error + ) + assert "Graph unavailable" in ( + entra_service.exchange_mailbox_permission_service_principals_error + ) + def test__resolve_identifiers_for_type_flags_only_404(self): """Only HTTP 404 / Request_ResourceNotFound mark an id as deleted. diff --git a/tests/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps_test.py b/tests/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps_test.py new file mode 100644 index 0000000000..dc61d32ffb --- /dev/null +++ b/tests/providers/m365/services/exchange/exchange_application_access_policy_restricts_mailbox_apps/exchange_application_access_policy_restricts_mailbox_apps_test.py @@ -0,0 +1,271 @@ +import importlib +from unittest import mock + +from prowler.providers.m365.services.entra import entra_service +from prowler.providers.m365.services.exchange import exchange_service +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.exchange." + "exchange_application_access_policy_restricts_mailbox_apps." + "exchange_application_access_policy_restricts_mailbox_apps" +) + + +class Test_exchange_application_access_policy_restricts_mailbox_apps: + def test_powershell_unavailable_returns_manual(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = None + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "ExchangeOnlineTenant" + assert "Exchange Online PowerShell is unavailable" in result[0].status_extended + + def test_no_resources(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 0 + + def test_graph_collection_unavailable_returns_manual(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = {} + entra_client.exchange_mailbox_permission_service_principals_error = ( + "RuntimeError: Graph unavailable" + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "ExchangeOnlineTenant" + assert ( + "Microsoft Graph mailbox permission collection failed" + in result[0].status_extended + ) + + def test_service_principal_without_policy_fails(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = { + "sp-id": entra_service.ServicePrincipal( + id="sp-id", + name="Mailbox App", + app_id="app-id", + exchange_mailbox_permissions=["Mail.Read", "Mail.Send"], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "sp-id" + assert result[0].resource_name == "Mailbox App" + assert "app-id" in result[0].status_extended + assert "Mail.Read, Mail.Send" in result[0].status_extended + + def test_service_principal_with_policy_passes(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [ + exchange_service.ApplicationAccessPolicy( + identity="policy-id", + app_id="app-id", + access_right="RestrictAccess", + description="Restrict mailbox access", + ) + ] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = { + "sp-id": entra_service.ServicePrincipal( + id="sp-id", + name="Mailbox App", + app_id="app-id", + exchange_mailbox_permissions=["Mail.Read"], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "sp-id" + assert ( + "is restricted using an Application Access Policy" + in result[0].status_extended + ) + + def test_service_principal_with_deny_access_policy_fails(self): + exchange_client = mock.MagicMock() + exchange_client.audited_tenant = "audited_tenant" + exchange_client.audited_domain = DOMAIN + exchange_client.organization_config = None + exchange_client.application_access_policies = [ + exchange_service.ApplicationAccessPolicy( + identity="policy-id", + app_id="app-id", + access_right="DenyAccess", + description="Deny mailbox access", + ) + ] + + entra_client = mock.MagicMock() + entra_client.exchange_mailbox_permission_service_principals = { + "sp-id": entra_service.ServicePrincipal( + id="sp-id", + name="Mailbox App", + app_id="app-id", + exchange_mailbox_permissions=["Mail.Read"], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.exchange_client", + new=exchange_client, + ), + mock.patch( + "prowler.providers.m365.services.exchange.exchange_application_access_policy_restricts_mailbox_apps.exchange_application_access_policy_restricts_mailbox_apps.entra_client", + new=entra_client, + ), + ): + check_module = importlib.import_module(CHECK_MODULE) + + result = ( + check_module.exchange_application_access_policy_restricts_mailbox_apps().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "sp-id" + assert result[0].resource_name == "Mailbox App" + assert ( + "is not restricted using an Application Access Policy" + in result[0].status_extended + ) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index c93d6abcac..5e78346376 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.32.0] (Prowler UNRELEASED) +## [1.32.0] (Prowler v5.32.0) ### ๐Ÿš€ Added diff --git a/ui/actions/findings/findings-triage.adapter.test.ts b/ui/actions/findings/findings-triage.adapter.test.ts index 6df034cc40..c84500677c 100644 --- a/ui/actions/findings/findings-triage.adapter.test.ts +++ b/ui/actions/findings/findings-triage.adapter.test.ts @@ -400,7 +400,6 @@ describe("adaptFindingTriageDetailResponse", () => { canEdit: true, noteBody: "Current note visible only inside the modal.", maxNoteLength: 500, - privacyCopy: "This note is only visible to your team.", }), ); }); diff --git a/ui/actions/findings/findings-triage.adapter.ts b/ui/actions/findings/findings-triage.adapter.ts index f00db7e33b..2ddfec94e9 100644 --- a/ui/actions/findings/findings-triage.adapter.ts +++ b/ui/actions/findings/findings-triage.adapter.ts @@ -1,7 +1,6 @@ import { FINDING_TRIAGE_BILLING_HREF, FINDING_TRIAGE_NOTE_MAX_LENGTH, - FINDING_TRIAGE_NOTE_PRIVACY_COPY, FINDING_TRIAGE_STATUS, FINDING_TRIAGE_STATUS_LABELS, type FindingTriageDetail, @@ -215,6 +214,5 @@ export function adaptFindingTriageDetailResponse( noteId: attributes.note_id || null, noteBody, maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH, - privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY, }; } diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index 0396795cb9..a9cef2cb36 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -6,7 +6,7 @@ import { LinkToFindings } from "@/components/overview"; import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; import { CardTitle } from "@/components/shadcn"; import { DataTable } from "@/components/ui/table"; -import { FINDINGS_FILTERED_SORT } from "@/lib"; +import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib"; import { createDict } from "@/lib/helper"; import { FindingProps, SearchParamsProps } from "@/types"; @@ -23,6 +23,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { const defaultFilters = { "filter[status]": "FAIL", "filter[delta]": "new", + "filter[muted]": MUTED_FILTER.EXCLUDE, }; const filters = pickFilterParams(searchParams); diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx index 7c56f7f683..bb43873da3 100644 --- a/ui/components/findings/table/column-finding-resources.test.tsx +++ b/ui/components/findings/table/column-finding-resources.test.tsx @@ -7,6 +7,13 @@ import type { } from "react"; import { describe, expect, it, vi } from "vitest"; +// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + vi.mock("@/components/shadcn", () => ({ Button: ({ children, ...props }: ButtonHTMLAttributes) => ( @@ -394,6 +401,38 @@ describe("column-finding-resources", () => { expect(screen.getByText(EDITING_UNAVAILABLE_COPY)).toBeInTheDocument(); }); + it("should keep the compact Triage label on resource cells for headerless nested rows", () => { + // Given + const columns = getColumnFindingResources({ + rowSelection: {}, + selectableRowCount: 1, + }); + const triageColumn = columns.find( + (col) => (col as { id?: string }).id === "triage", + ); + if (!triageColumn?.cell) { + throw new Error("triage column not found"); + } + const CellComponent = triageColumn.cell as (props: { + row: { original: FindingResourceRow }; + }) => ReactNode; + + // When + render( +
+ {CellComponent({ + row: { + original: makeResource({ triage: makeTriageSummary() }), + }, + })} +
, + ); + + // Then โ€” expanded finding-group rows render without a header row, so the + // cell itself must carry the label, like Service/Region/Last seen do. + expect(screen.getByText("Triage")).toBeInTheDocument(); + }); + it("should disable non-paying Cloud triage control with only-in-Cloud tooltip copy", () => { // Given const columns = getColumnFindingResources({ diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index 37761e1e87..7ab2cb01ff 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -354,17 +354,20 @@ export function getColumnFindingResources({ }, enableSorting: false, }, - // Triage + // Triage โ€” keep the compact label: these cells also render inside + // expanded finding-group rows, which have no header row of their own. { id: "triage", header: ({ column }) => ( ), cell: ({ row }) => ( - + + + ), enableSorting: false, }, diff --git a/ui/components/findings/table/column-standalone-findings.test.tsx b/ui/components/findings/table/column-standalone-findings.test.tsx index 94d0f86adc..7b9a2afe8c 100644 --- a/ui/components/findings/table/column-standalone-findings.test.tsx +++ b/ui/components/findings/table/column-standalone-findings.test.tsx @@ -5,6 +5,13 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }), })); +// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + vi.mock("@/components/findings/mute-findings-modal", () => ({ MuteFindingsModal: () => null, })); diff --git a/ui/components/findings/table/column-standalone-findings.tsx b/ui/components/findings/table/column-standalone-findings.tsx index e8837d0462..7a7c8bb8b4 100644 --- a/ui/components/findings/table/column-standalone-findings.tsx +++ b/ui/components/findings/table/column-standalone-findings.tsx @@ -67,7 +67,7 @@ function FindingTitleCell({ finding={finding} defaultOpen={defaultOpen} trigger={ -
+

{finding.attributes.check_metadata.checktitle}

diff --git a/ui/components/findings/table/finding-note-modal.test.tsx b/ui/components/findings/table/finding-note-modal.test.tsx index 73b6a2f61c..4d40c382a3 100644 --- a/ui/components/findings/table/finding-note-modal.test.tsx +++ b/ui/components/findings/table/finding-note-modal.test.tsx @@ -9,6 +9,13 @@ vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({ ), })); +// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + vi.mock("@/components/shadcn/modal", () => ({ Modal: ({ children, @@ -44,7 +51,6 @@ beforeAll(() => { import { FINDING_TRIAGE_DISABLED_REASON, - FINDING_TRIAGE_NOTE_PRIVACY_COPY, FINDING_TRIAGE_STATUS, type FindingTriageDetail, type UpdateFindingTriageInput, @@ -72,7 +78,6 @@ function makeTriageDetail( noteId: "note-1", noteBody: "Existing investigation note", maxNoteLength: 500, - privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY, ...overrides, }; } @@ -246,7 +251,45 @@ describe("FindingNoteModal", () => { expect(onOpenChange).not.toHaveBeenCalledWith(false); }); - it("should render counter, privacy copy, and cancel/update actions", async () => { + it("should lock the status picker for resolved findings while keeping the note editable", async () => { + // Given + const user = userEvent.setup(); + const onTriageUpdateAction = vi.fn(); + renderNoteModal({ + triage: makeTriageDetail({ + status: FINDING_TRIAGE_STATUS.RESOLVED, + label: "Resolved", + }), + onTriageUpdateAction, + }); + + // Then โ€” automation owns the transition out of Resolved. + expect( + screen.getByRole("combobox", { name: "Triage status" }), + ).toBeDisabled(); + expect( + screen.getByText( + "Triage status is managed automatically once the finding is resolved.", + ), + ).toBeVisible(); + expect(screen.getByLabelText("Note text")).toBeEnabled(); + + // When โ€” the note itself can still be updated. + const textarea = screen.getByLabelText("Note text"); + await user.clear(textarea); + await user.type(textarea, "Documenting the resolution."); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + // Then + expect(onTriageUpdateAction).toHaveBeenCalledWith( + expect.objectContaining({ note: "Documenting the resolution." }), + ); + expect(onTriageUpdateAction).toHaveBeenCalledWith( + expect.not.objectContaining({ status: expect.anything() }), + ); + }); + + it("should render counter and cancel/update actions without privacy copy", async () => { // Given const user = userEvent.setup(); const onOpenChange = vi.fn(); @@ -258,7 +301,9 @@ describe("FindingNoteModal", () => { // Then expect(screen.getByText("3/500")).toBeInTheDocument(); - expect(screen.getByText(FINDING_TRIAGE_NOTE_PRIVACY_COPY)).toBeVisible(); + expect( + screen.queryByText("This note is only visible to your team."), + ).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(onOpenChange).toHaveBeenCalledWith(false); expect( @@ -305,7 +350,11 @@ describe("FindingNoteModal", () => { await user.click(screen.getByRole("option", { name: "Risk Accepted" })); // Then - expect(screen.getByText(/will be muted/i)).toBeVisible(); + expect( + screen.getByText( + "Changing triage to Risk Accepted will mute the finding", + ), + ).toBeVisible(); await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument(), ); diff --git a/ui/components/findings/table/finding-note-modal.tsx b/ui/components/findings/table/finding-note-modal.tsx index 02f1557579..90c9241017 100644 --- a/ui/components/findings/table/finding-note-modal.tsx +++ b/ui/components/findings/table/finding-note-modal.tsx @@ -5,14 +5,24 @@ import { type FormEvent, useRef, useState } from "react"; import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; import { Alert, AlertDescription, Button, Textarea } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { DOCS_URLS } from "@/lib/external-urls"; import { FINDING_TRIAGE_DISABLED_REASON, FINDING_TRIAGE_ORIGIN, + FINDING_TRIAGE_RESOLVED_LOCKED_COPY, FINDING_TRIAGE_STATUS, type FindingTriageDetail, type FindingTriageStatus, + getFindingTriageMuteInfoCopy, isMutelistShortcutStatus, + isTriageStatusLocked, } from "@/types/findings-triage"; import type { ProviderType } from "@/types/providers"; @@ -37,10 +47,8 @@ interface FindingNoteModalProps { onTriageUpdateAction?: FindingTriageUpdateHandler; } -const MUTELIST_INFO_COPY = - "This finding will be muted through the existing Mutelist flow."; const REMEDIATING_INFO_COPY = - "Once this finding is fixed and passes in the next scan, it will be automatically changed to Resolved."; + "Once this finding is remediated, if in the following scan its status changes to Pass, it will be automatically changed to Resolved"; export function FindingNoteModal({ open, @@ -68,6 +76,7 @@ export function FindingNoteModal({ isMutelistShortcutStatus(selectedStatus); const shouldShowRemediatingInfo = selectedStatus === FINDING_TRIAGE_STATUS.REMEDIATING; + const isStatusLocked = isTriageStatusLocked(triage.status); // Opened from a dropdown item: move focus into the dialog on mount so Radix's // aria-hidden is not applied to the still-focused dropdown that opened it. const handleOpenAutoFocus = (event: Event) => { @@ -118,7 +127,10 @@ export function FindingNoteModal({ title="Add Triage Note" size="lg" > -
+ {/* min-w-0: the form is a grid item of DialogContent; without it, long + unbreakable content (e.g. resource UIDs) widens the grid track past + the modal instead of truncating. */} +
{findingContext.providerType ? ( @@ -129,12 +141,17 @@ export function FindingNoteModal({ )}
-
-

- {findingContext.title} -

+
+ + +

+ {findingContext.title} +

+
+ {findingContext.title} +
{(findingContext.resource || findingContext.provider) && ( -

+

{[findingContext.resource, findingContext.provider] .filter(Boolean) .join(" ยท ")} @@ -143,27 +160,44 @@ export function FindingNoteModal({

-
+
Status: - +
+ +
+ {isStatusLocked && ( + + + {FINDING_TRIAGE_RESOLVED_LOCKED_COPY} + + + )} + {shouldShowMutelistInfo && ( - {MUTELIST_INFO_COPY} + + {getFindingTriageMuteInfoCopy(selectedStatus)} + )} {shouldShowRemediatingInfo && ( - {REMEDIATING_INFO_COPY} + + {REMEDIATING_INFO_COPY}.{" "} + + Learn more + + )} @@ -184,10 +218,7 @@ export function FindingNoteModal({ textareaSize="lg" onChange={(event) => setNote(event.target.value)} /> -
-

- {triage.privacyCopy} -

+

{note.length}/{triage.maxNoteLength}

diff --git a/ui/components/findings/table/finding-triage-cells.test.tsx b/ui/components/findings/table/finding-triage-cells.test.tsx index eeb67db2eb..3d0a417860 100644 --- a/ui/components/findings/table/finding-triage-cells.test.tsx +++ b/ui/components/findings/table/finding-triage-cells.test.tsx @@ -8,14 +8,17 @@ vi.mock("@/components/shadcn/modal", () => ({ children, open, title, + description, }: { children: ReactNode; open: boolean; title?: string; + description?: string; }) => open ? (

{title}

+ {description &&

{description}

} {children}
) : null, @@ -27,6 +30,13 @@ vi.mock("next/navigation", () => ({ }), })); +// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + vi.mock("@/components/shadcn/dropdown", () => ({ ActionDropdownItem: ({ label, @@ -66,6 +76,7 @@ import { import { FindingNoteActionItem, + FindingTriageStatusBadge, FindingTriageStatusCell, } from "./finding-triage-cells"; @@ -162,11 +173,6 @@ describe("finding triage cells", () => { await user.click(statusControl); // Then - expect(screen.getByText("Triage").parentElement).toHaveClass( - "text-text-neutral-secondary", - "text-[10px]", - "whitespace-nowrap", - ); expect(statusControl.parentElement).toHaveClass("w-32"); expect(statusControl).toHaveAttribute("data-size", "xs"); expect(within(statusControl).getByText("Under Review")).toHaveClass( @@ -197,6 +203,22 @@ describe("finding triage cells", () => { ).toHaveClass("text-text-neutral-secondary"); }); + it("renders a read-only triage status badge with the status color", () => { + // Given / When + render( + , + ); + + // Then + expect(screen.getByText("Triage:")).toBeInTheDocument(); + expect(screen.getByText("Remediating")).toHaveClass("text-bg-data-info"); + }); + it("should disable table status mutation when no update handler is wired", async () => { // Given const user = userEvent.setup(); @@ -222,6 +244,57 @@ describe("finding triage cells", () => { expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); }); + it("should lock the table status picker for resolved findings", async () => { + // Given + const user = userEvent.setup(); + const onTriageUpdateAction = vi.fn(); + render( + , + ); + + const statusControl = screen.getByRole("combobox", { + name: "Triage status", + }); + + // When + await user.click(statusControl); + + // Then โ€” automation owns the transition out of Resolved. + expect(statusControl).toBeDisabled(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect( + screen.getAllByText( + "Triage status is managed automatically once the finding is resolved.", + ).length, + ).toBeGreaterThan(0); + expect(onTriageUpdateAction).not.toHaveBeenCalled(); + }); + + it("should keep the note action available for resolved findings", () => { + // Given + render( + , + ); + + // Then โ€” the lock only applies to status transitions, not notes. + expect( + screen.getByRole("button", { name: "Add Triage Note" }), + ).toBeEnabled(); + }); + it("should not open an editable empty-note modal for an existing note without a loader", async () => { // Given const user = userEvent.setup(); @@ -647,6 +720,11 @@ describe("finding triage cells", () => { // Then: the user is warned before the server action handles muting. expect(screen.getByRole("dialog", { name: "Mute finding?" })).toBeVisible(); + expect( + screen.getByText( + "Changing triage to False Positive will mute the finding", + ), + ).toBeVisible(); expect(onTriageUpdateAction).not.toHaveBeenCalled(); // When diff --git a/ui/components/findings/table/finding-triage-cells.tsx b/ui/components/findings/table/finding-triage-cells.tsx index d86551da40..1ce3f98667 100644 --- a/ui/components/findings/table/finding-triage-cells.tsx +++ b/ui/components/findings/table/finding-triage-cells.tsx @@ -9,16 +9,18 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { cn } from "@/lib/utils"; import { FINDING_TRIAGE_DISABLED_REASON, FINDING_TRIAGE_NOTE_MAX_LENGTH, - FINDING_TRIAGE_NOTE_PRIVACY_COPY, FINDING_TRIAGE_ORIGIN, + FINDING_TRIAGE_RESOLVED_LOCKED_COPY, FINDING_TRIAGE_STATUS_LABELS, type FindingTriageDetail, type FindingTriageLoadedNote, type FindingTriageStatus, type FindingTriageSummary, + isTriageStatusLocked, type UpdateFindingTriageInput, } from "@/types/findings-triage"; @@ -29,6 +31,7 @@ import { import { FindingTriageStatusControl, type FindingTriageUpdateHandler, + TRIAGE_STATUS_TEXT_CLASS, } from "./finding-triage-status-control"; export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud"; @@ -37,14 +40,21 @@ export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable."; const getDisabledCopy = ({ triage, hasUpdateHandler, + lockResolved = false, }: { triage: FindingTriageSummary; hasUpdateHandler: boolean; + lockResolved?: boolean; }): string | undefined => { if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY) { return CLOUD_ONLY_TOOLTIP_COPY; } + // Status-picker only: notes stay available on resolved findings. + if (lockResolved && isTriageStatusLocked(triage.status)) { + return FINDING_TRIAGE_RESOLVED_LOCKED_COPY; + } + if (triage.canEdit && !hasUpdateHandler) { return EDITING_UNAVAILABLE_COPY; } @@ -60,7 +70,6 @@ const getTriageDetailFromSummary = ( noteId: loadedNote?.noteId ?? null, noteBody: loadedNote?.noteBody ?? "", maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH, - privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY, }); export function FindingTriageStatusCell({ @@ -151,6 +160,7 @@ export function FindingTriageStatusCell({ const disabledCopy = getDisabledCopy({ triage, hasUpdateHandler: Boolean(onTriageUpdateAction), + lockResolved: true, }); if (!disabledCopy) { return control; @@ -159,8 +169,7 @@ export function FindingTriageStatusCell({ return ( - {/* Block-level so the tooltip wrapper doesn't add inline baseline spacing - that would push the compact "Triage" label below the sibling columns. */} + {/* Block-level wrapper keeps the picker aligned with the sibling columns. */} {control} {disabledCopy} @@ -168,6 +177,28 @@ export function FindingTriageStatusCell({ ); } +// Read-only triage status indicator, e.g. for the side drawer header where the +// editable picker would be out of place among the status/severity badges. +export function FindingTriageStatusBadge({ + triage, +}: { + triage: FindingTriageSummary; +}) { + return ( +
+ Triage: + + {triage.label} + +
+ ); +} + export function FindingNoteActionItem({ triage, findingContext = { title: "Finding" }, diff --git a/ui/components/findings/table/finding-triage-status-control.tsx b/ui/components/findings/table/finding-triage-status-control.tsx index e50fac1157..2b6a7a2407 100644 --- a/ui/components/findings/table/finding-triage-status-control.tsx +++ b/ui/components/findings/table/finding-triage-status-control.tsx @@ -3,7 +3,6 @@ import { type ComponentProps, useState } from "react"; import { Button } from "@/components/shadcn"; -import { InfoField } from "@/components/shadcn/info-field/info-field"; import { Modal } from "@/components/shadcn/modal"; import { Select, @@ -19,8 +18,10 @@ import { type FindingTriageManualStatus, type FindingTriageStatus, type FindingTriageSummary, + getFindingTriageMuteInfoCopy, isManualStatus, isMutelistShortcutStatus, + isTriageStatusLocked, type UpdateFindingTriageInput, } from "@/types/findings-triage"; @@ -32,7 +33,7 @@ type TriageStatusPickerSize = NonNullable< ComponentProps["size"] >; -const TRIAGE_STATUS_TEXT_CLASS = { +export const TRIAGE_STATUS_TEXT_CLASS = { open: "text-text-error-primary", under_review: "text-text-warning-primary", remediating: "text-bg-data-info", @@ -43,8 +44,6 @@ const TRIAGE_STATUS_TEXT_CLASS = { } as const satisfies Record; const MUTELIST_CONFIRMATION_TITLE = "Mute finding?"; -const MUTELIST_CONFIRMATION_COPY = - "Changing to this triage status will mute the finding."; function TriageStatusPicker({ disabled, @@ -119,7 +118,7 @@ export function FindingTriageStatusControl( if (props.origin === FINDING_TRIAGE_ORIGIN.MODAL) { return ( @@ -127,7 +126,10 @@ export function FindingTriageStatusControl( } const canMutateFromTable = - triage.canEdit && Boolean(props.onTriageUpdateAction) && !isTableUpdating; + triage.canEdit && + Boolean(props.onTriageUpdateAction) && + !isTableUpdating && + !isTriageStatusLocked(triage.status); const applyTableStatus = async (status: FindingTriageManualStatus) => { if (!props.onTriageUpdateAction || status === triage.status) { @@ -174,16 +176,14 @@ export function FindingTriageStatusControl( return ( <> - -
- -
-
+
+ +
{tableUpdateError && ( {tableUpdateError} @@ -197,7 +197,11 @@ export function FindingTriageStatusControl( } }} title={MUTELIST_CONFIRMATION_TITLE} - description={MUTELIST_CONFIRMATION_COPY} + description={ + pendingShortcutStatus + ? getFindingTriageMuteInfoCopy(pendingShortcutStatus) + : undefined + } size="sm" >
diff --git a/ui/components/findings/table/finding-triage-submit.test.ts b/ui/components/findings/table/finding-triage-submit.test.ts index 15ca3e70ea..12315129f0 100644 --- a/ui/components/findings/table/finding-triage-submit.test.ts +++ b/ui/components/findings/table/finding-triage-submit.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { FINDING_TRIAGE_NOTE_MAX_LENGTH, - FINDING_TRIAGE_NOTE_PRIVACY_COPY, FINDING_TRIAGE_STATUS, type FindingTriageDetail, } from "@/types/findings-triage"; @@ -26,7 +25,6 @@ function makeTriageDetail( noteId: "note-1", noteBody: "Existing investigation note", maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH, - privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY, ...overrides, }; } diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index dcb66a2865..d413b8ac08 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -461,6 +461,12 @@ vi.mock("../finding-triage-cells", () => ({ ) : ( - ), + FindingTriageStatusBadge: ({ triage }: { triage: { label: string } }) => ( +
+ Triage: + {triage.label} +
+ ), })); vi.mock("./resource-detail-skeleton", () => ({ diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 3ce3258c5b..64feea3b60 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -84,6 +84,7 @@ import { Muted } from "../../muted"; import { DeltaIndicator } from "../delta-indicator"; import { FindingNoteActionItem, + FindingTriageStatusBadge, FindingTriageStatusCell, } from "../finding-triage-cells"; import { DeltaValues, NotificationIndicator } from "../notification-indicator"; @@ -560,6 +561,7 @@ export function ResourceDetailDrawerContent({ {findingIsMuted !== undefined && ( )} + {findingTriage && }
{showCheckMetaContent ? ( diff --git a/ui/components/overview/new-findings-table/table/column-latest-findings.tsx b/ui/components/overview/new-findings-table/table/column-latest-findings.tsx index 6310e2dc40..0b2737bad9 100644 --- a/ui/components/overview/new-findings-table/table/column-latest-findings.tsx +++ b/ui/components/overview/new-findings-table/table/column-latest-findings.tsx @@ -2,10 +2,18 @@ import { ColumnDef } from "@tanstack/react-table"; +import { + loadLatestFindingTriageNote, + updateFindingTriage, +} from "@/actions/findings"; import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings"; import { FindingProps } from "@/types"; export const ColumnLatestFindings: ColumnDef[] = getStandaloneFindingColumns({ includeUpdatedAt: true, + onTriageUpdateAction: async (input) => { + await updateFindingTriage(input); + }, + onTriageNoteLoadAction: loadLatestFindingTriageNote, }); diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index 90bc0b4b5d..110741fd12 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -93,7 +93,7 @@ describe("LaunchStep", () => { ); // Then - expect(screen.getByText("Account Connected!")).toBeInTheDocument(); + expect(screen.getByText("Provider Connected!")).toBeInTheDocument(); expect( screen.getByRole("radio", { name: "On a schedule" }), ).toBeChecked(); diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 27af2b09f2..cb6c8be2b4 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -296,11 +296,11 @@ export function LaunchStep({
-

Account Connected!

+

Provider Connected!

- Your account is connected to Prowler and ready to Scan! + Your provider is connected to Prowler and ready to Scan!

{!providerId && ( diff --git a/ui/components/shadcn/dialog.tsx b/ui/components/shadcn/dialog.tsx index 63721874a1..e260eae95c 100644 --- a/ui/components/shadcn/dialog.tsx +++ b/ui/components/shadcn/dialog.tsx @@ -85,7 +85,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index f56461e7a5..f8eb1b7d73 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -6,6 +6,8 @@ export const DOCS_URLS = { "https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings", FINDINGS_INGESTION: "https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings", + FINDINGS_TRIAGE: + "https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage", AWS_ORGANIZATIONS: "https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations", ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts", diff --git a/ui/types/findings-triage.ts b/ui/types/findings-triage.ts index 6dcfe8ef5f..c0a9c9ef02 100644 --- a/ui/types/findings-triage.ts +++ b/ui/types/findings-triage.ts @@ -54,6 +54,17 @@ export const isMutelistShortcutStatus = (status: unknown): boolean => { ); }; +export const getFindingTriageMuteInfoCopy = (status: FindingTriageStatus) => + `Changing triage to ${FINDING_TRIAGE_STATUS_LABELS[status]} will mute the finding`; + +// Only RESOLVED locks manual edits: automation owns the transition out of it +// (REOPENED on a failing rescan), while REOPENED invites human re-triage. +export const isTriageStatusLocked = (status: FindingTriageStatus): boolean => + status === FINDING_TRIAGE_STATUS.RESOLVED; + +export const FINDING_TRIAGE_RESOLVED_LOCKED_COPY = + "Triage status is managed automatically once the finding is resolved." as const; + export const FINDING_TRIAGE_DISABLED_REASON = { CLOUD_ONLY: "cloud_only", FORBIDDEN: "forbidden", @@ -69,8 +80,6 @@ export const FINDING_TRIAGE_ORIGIN = { } as const; export const FINDING_TRIAGE_NOTE_MAX_LENGTH = 500 as const; -export const FINDING_TRIAGE_NOTE_PRIVACY_COPY = - "This note is only visible to your team." as const; export const FINDING_TRIAGE_BILLING_HREF = "https://prowler.com/pricing" as const; @@ -92,7 +101,6 @@ export interface FindingTriageDetail extends FindingTriageSummary { noteId: string | null; noteBody: string; maxNoteLength: typeof FINDING_TRIAGE_NOTE_MAX_LENGTH; - privacyCopy: typeof FINDING_TRIAGE_NOTE_PRIVACY_COPY; } export interface UpdateFindingTriageInput { diff --git a/uv.lock b/uv.lock index 3365c13f17..eb1f14befb 100644 --- a/uv.lock +++ b/uv.lock @@ -3553,7 +3553,7 @@ wheels = [ [[package]] name = "prowler" -version = "5.32.0" +version = "5.33.0" source = { editable = "." } dependencies = [ { name = "alibabacloud-actiontrail20200706" },