Compare commits

...

13 Commits

Author SHA1 Message Date
Prowler Bot 4ec493ec5c fix(api): correct service principal for Bedrock AgentCore attack paths (#11152)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
2026-05-13 12:41:10 +02:00
Prowler Bot 85c1b85852 fix(ui): render inline code without literal backticks in finding drawer (#11155)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-05-13 10:47:13 +01:00
Prowler Bot 8f50c6d684 fix(m365): surface AuditLog.Read.All permission errors instead of false positives (#11146)
Co-authored-by: abdou <b-abderrahmane@outlook.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-05-12 18:42:21 +01:00
Prowler Bot 1fb6c6a0f0 chore(api): Bump version to v1.27.2 (#11135)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-12 15:08:06 +02:00
Prowler Bot fc3a25d7a8 chore(sdk): Bump version to v5.26.2 (#11133)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-12 15:07:49 +02:00
Prowler Bot 1a56087ea0 chore(ui): Bump version to v5.26.2 (#11134)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-12 15:07:33 +02:00
Prowler Bot 2fdc480beb fix(ui): fix role cancel and select dropdown scroll (#11128)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-05-12 13:21:18 +02:00
Prowler Bot 8bfc1d85f5 chore(changelog): prepare changelog for v5.26.1 (#11130)
Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-05-12 13:18:44 +02:00
Prowler Bot 57501e1864 fix(api): defer scan broker publish until transaction commits (#11123)
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
2026-05-12 12:12:40 +02:00
Prowler Bot 02a83adfd4 fix(m365): exclude disabled guest users from entra_users_mfa_capable (#11119)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-05-12 08:50:44 +01:00
Prowler Bot 98a1bca403 chore(api): Bump version to v1.27.1 (#11114)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-11 15:36:28 +02:00
Prowler Bot 8aade7f024 chore(sdk): Bump version to v5.26.1 (#11111)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-11 15:36:17 +02:00
Prowler Bot 43b50c4d6f chore(ui): Bump version to v5.26.1 (#11113)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-11 15:35:40 +02:00
25 changed files with 769 additions and 58 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.26.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.26.2
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+16
View File
@@ -2,6 +2,22 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.27.2] (Prowler UNRELEASED)
### 🐞 Fixed
- Attack Paths: BEDROCK-001 and BEDROCK-002 now target roles trusting `bedrock-agentcore.amazonaws.com` instead of `bedrock.amazonaws.com`, eliminating false positives against regular Bedrock service roles (Agents, Knowledge Bases, model invocation) [(#11141)](https://github.com/prowler-cloud/prowler/pull/11141)
---
## [1.27.1] (Prowler v5.26.1)
### 🐞 Fixed
- `POST /api/v1/scans` was intermittently failing with `Scan matching query does not exist` in the `scan-perform` worker; the Celery task is now published via `transaction.on_commit` so the worker cannot read the Scan before the dispatch-wide transaction commits [(#11122)](https://github.com/prowler-cloud/prowler/pull/11122)
---
## [1.27.0] (Prowler v5.26.0)
### 🚀 Added
+1 -1
View File
@@ -50,7 +50,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.27.0"
version = "1.27.2"
[project.scripts]
celery = "src.backend.config.settings.celery"
@@ -484,8 +484,8 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition(
OR action = '*'
)
// Find roles that trust Bedrock service (can be passed to Bedrock)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}})
// Find roles that trust the Bedrock AgentCore service (can be passed to a code interpreter)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}})
WHERE any(resource IN stmt_passrole.resource WHERE
resource = '*'
OR target_role.arn CONTAINS resource
@@ -536,8 +536,8 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition(
OR action = '*'
)
// Find roles that trust Bedrock service (already attached to existing code interpreters)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}})
// Find roles that trust the Bedrock AgentCore service (already attached to existing code interpreters)
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}})
WITH collect(path_principal) + collect(path_target) AS paths
UNWIND paths AS p
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.27.0
version: 1.27.2
description: |-
Prowler API specification.
+38 -19
View File
@@ -4,6 +4,7 @@ import json
import logging
import os
import time
import uuid
from collections import defaultdict
from copy import deepcopy
from datetime import datetime, timedelta, timezone
@@ -16,7 +17,7 @@ from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
from celery import chain
from celery import chain, states
from celery.result import AsyncResult
from config.custom_logging import BackendLogger
from config.env import env
@@ -60,6 +61,7 @@ from django.utils.dateparse import parse_date
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_control
from django_celery_beat.models import PeriodicTask
from django_celery_results.models import TaskResult
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import (
@@ -422,7 +424,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.27.0"
spectacular_settings.VERSION = "1.27.2"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -2534,28 +2536,45 @@ class ScanViewSet(BaseRLSViewSet):
def create(self, request, *args, **kwargs):
input_serializer = self.get_serializer(data=request.data)
input_serializer.is_valid(raise_exception=True)
# Broker publish is deferred to on_commit so the worker cannot read
# Scan before BaseRLSViewSet's dispatch-wide atomic commits.
pre_task_id = str(uuid.uuid4())
with transaction.atomic():
scan = input_serializer.save()
with transaction.atomic():
task = perform_scan_task.apply_async(
kwargs={
"tenant_id": self.request.tenant_id,
"scan_id": str(scan.id),
"provider_id": str(scan.provider_id),
# Disabled for now
# checks_to_execute=scan.scanner_args.get("checks_to_execute")
},
scan.task_id = pre_task_id
scan.save(update_fields=["task_id"])
attack_paths_db_utils.create_attack_paths_scan(
tenant_id=self.request.tenant_id,
scan_id=str(scan.id),
provider_id=str(scan.provider_id),
)
attack_paths_db_utils.create_attack_paths_scan(
tenant_id=self.request.tenant_id,
scan_id=str(scan.id),
provider_id=str(scan.provider_id),
)
task_result, _ = TaskResult.objects.get_or_create(
task_id=pre_task_id,
defaults={"status": states.PENDING, "task_name": "scan-perform"},
)
prowler_task, _ = Task.objects.update_or_create(
id=pre_task_id,
tenant_id=self.request.tenant_id,
defaults={"task_runner_task": task_result},
)
prowler_task = Task.objects.get(id=task.id)
scan.task_id = task.id
scan.save(update_fields=["task_id"])
scan_kwargs = {
"tenant_id": self.request.tenant_id,
"scan_id": str(scan.id),
"provider_id": str(scan.provider_id),
# Disabled for now
# checks_to_execute=scan.scanner_args.get("checks_to_execute")
}
transaction.on_commit(
lambda: perform_scan_task.apply_async(
kwargs=scan_kwargs, task_id=pre_task_id
)
)
self.response_serializer_class = TaskSerializer
output_serializer = self.get_serializer(prowler_task)
+16
View File
@@ -2,6 +2,22 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.26.2] (Prowler UNRELEASED)
### 🐞 Fixed
- `entra_users_mfa_capable` and `entra_break_glass_account_fido2_security_key_registered` report a preventive FAIL per affected user (with the missing permission named) when the M365 service principal lacks `AuditLog.Read.All`, instead of mass false positives [(#10907)](https://github.com/prowler-cloud/prowler/pull/10907)
---
## [5.26.1] (Prowler v5.26.1)
### 🐞 Fixed
- `entra_users_mfa_capable` no longer flags disabled guest users by requesting `accountEnabled` and `userType` from Microsoft Graph via `$select` and using Graph as the source of truth for `account_enabled` (EXO `Get-User` does not return guest users) [(#11002)](https://github.com/prowler-cloud/prowler/pull/11002)
---
## [5.26.0] (Prowler v5.26.0)
### 🚀 Added
+1 -1
View File
@@ -48,7 +48,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.26.0"
prowler_version = "5.26.2"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
@@ -85,6 +85,15 @@ class entra_break_glass_account_fido2_security_key_registered(Check):
resource_id=user.id,
)
if entra_client.user_registration_details_error:
report.status = "FAIL"
report.status_extended = (
f"Cannot verify FIDO2 security key registration for break glass account {user.name}: "
f"{entra_client.user_registration_details_error}."
)
findings.append(report)
continue
auth_methods = set(user.authentication_methods)
has_fido2 = "fido2SecurityKey" in auth_methods
has_passkey_device_bound = "passKeyDeviceBound" in auth_methods
@@ -2,13 +2,15 @@ import asyncio
import json
from asyncio import gather
from enum import Enum
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from uuid import UUID
from kiota_abstractions.base_request_configuration import RequestConfiguration
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
from prowler.lib.logger import logger
@@ -71,6 +73,7 @@ class Entra(M365Service):
)
self.tenant_domain = provider.identity.tenant_domain
self.user_registration_details_error: Optional[str] = None
attributes = loop.run_until_complete(
gather(
self._get_authorization_policy(),
@@ -806,7 +809,29 @@ class Entra(M365Service):
logger.info("Entra - Getting users...")
users = {}
try:
users_response = await self.client.users.get()
# Microsoft Graph's /users endpoint omits accountEnabled, userType and
# onPremisesSyncEnabled from the default property set, so we must request
# them explicitly via $select. Without this, disabled guest users surface
# as account_enabled=True (Pydantic default) and user_type=None, which
# bypasses the guest/disabled filters in checks like
# entra_users_mfa_capable (CIS 5.2.3.4). See issue #10921.
query_parameters = (
UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
select=[
"id",
"displayName",
"userType",
"accountEnabled",
"onPremisesSyncEnabled",
],
)
)
request_configuration = RequestConfiguration(
query_parameters=query_parameters,
)
users_response = await self.client.users.get(
request_configuration=request_configuration,
)
directory_roles = await self.client.directory_roles.get()
async def fetch_role_members(directory_role):
@@ -825,11 +850,26 @@ class Entra(M365Service):
for member in members:
user_roles_map.setdefault(member.id, []).append(role_template_id)
registration_details = await self._get_user_registration_details()
registration_details, self.user_registration_details_error = (
await self._get_user_registration_details()
)
while users_response:
for user in getattr(users_response, "value", []) or []:
reg_info = registration_details.get(user.id, {})
# Prefer Microsoft Graph as the source of truth for
# accountEnabled: it covers every directory user including
# guests, whereas EXO's Get-User only returns mail-enabled
# accounts and silently drops disabled guests. Fall back to
# the EXO PowerShell value only when Graph does not return a
# value (e.g. older tenants or permission-restricted reads).
graph_account_enabled = getattr(user, "account_enabled", None)
if graph_account_enabled is None:
account_enabled = not self.user_accounts_status.get(
user.id, {}
).get("AccountDisabled", False)
else:
account_enabled = bool(graph_account_enabled)
users[user.id] = User(
id=user.id,
name=user.display_name,
@@ -838,9 +878,7 @@ class Entra(M365Service):
),
directory_roles_ids=user_roles_map.get(user.id, []),
is_mfa_capable=reg_info.get("is_mfa_capable", False),
account_enabled=not self.user_accounts_status.get(
user.id, {}
).get("AccountDisabled", False),
account_enabled=account_enabled,
authentication_methods=reg_info.get(
"authentication_methods", []
),
@@ -857,18 +895,24 @@ class Entra(M365Service):
)
return users
async def _get_user_registration_details(self):
async def _get_user_registration_details(
self,
) -> Tuple[Dict[str, Dict[str, Any]], Optional[str]]:
"""Retrieve user authentication method registration details.
Fetches registration details from the Microsoft Graph API, including
MFA capability and the specific authentication methods each user has registered.
Returns:
dict: A dictionary mapping user IDs to their registration details,
where each value is a dict with 'is_mfa_capable' (bool) and
'authentication_methods' (list of str).
A tuple containing:
- A dictionary mapping user IDs to their registration details,
where each value is a dict with 'is_mfa_capable' (bool) and
'authentication_methods' (list of str), or an empty dict if
retrieval fails.
- An error message string if there was an access error, None otherwise.
"""
registration_details = {}
error_message = None
try:
registration_builder = (
self.client.reports.authentication_methods.user_registration_details
@@ -893,16 +937,25 @@ class Entra(M365Service):
next_link
).get()
except Exception as error:
if (
error.__class__.__name__ == "ODataError"
and error.__dict__.get("response_status_code", None) == 403
):
except ODataError as error:
error_code = getattr(error.error, "code", None) if error.error else None
if error_code == "Authorization_RequestDenied":
error_message = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_message}"
)
else:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
error_message = str(error)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
error_message = f"Failed to retrieve user registration details: {error}"
return registration_details
return registration_details, error_message
async def _get_oauth_apps(self) -> Optional[Dict[str, "OAuthApp"]]:
"""
@@ -13,6 +13,10 @@ class entra_users_mfa_capable(Check):
("Ensure all member users are 'MFA capable'").
Guest users and disabled accounts are excluded from the evaluation.
- PASS: The member user is MFA capable.
- FAIL: The member user is not MFA capable, or MFA capability cannot be
verified due to insufficient permissions to read user registration details.
"""
def execute(self) -> List[CheckReportM365]:
@@ -42,7 +46,13 @@ class entra_users_mfa_capable(Check):
resource_id=user.id,
)
if not user.is_mfa_capable:
if entra_client.user_registration_details_error:
report.status = "FAIL"
report.status_extended = (
f"Cannot verify MFA capability for user {user.name}: "
f"{entra_client.user_registration_details_error}."
)
elif not user.is_mfa_capable:
report.status = "FAIL"
report.status_extended = f"User {user.name} is not MFA capable."
else:
+1 -1
View File
@@ -95,7 +95,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.13"
version = "5.26.0"
version = "5.26.2"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -67,6 +67,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -104,6 +105,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -142,6 +144,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -178,6 +181,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -228,6 +232,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -275,6 +280,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -321,6 +327,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -368,6 +375,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -422,6 +430,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -457,6 +466,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -500,3 +510,117 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == "BreakGlass1"
def test_user_registration_details_permission_error(self):
"""Test FAIL when there's a permission error reading user registration details."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
policy_id = str(uuid4())
bg_user_id = str(uuid4())
entra_client.conditional_access_policies = {
policy_id: _make_policy(policy_id, excluded_users=[bg_user_id]),
}
entra_client.users = {
bg_user_id: User(
id=bg_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=[],
),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"Cannot verify FIDO2 security key registration for break glass account BreakGlass1"
in result[0].status_extended
)
assert "AuditLog.Read.All" in result[0].status_extended
assert result[0].resource_name == "BreakGlass1"
assert result[0].resource_id == bg_user_id
def test_user_registration_details_permission_error_with_missing_user(self):
"""Per-user emission and missing-user short-circuit on the error path.
Two break-glass user IDs are excluded from all CAPs, but only one is
present in ``entra_client.users``. With ``user_registration_details_error``
set, the present user must produce one preventive FAIL anchored to the
real user; the missing user must be skipped by the existing
``if not user: continue`` guard rather than crash or yield a synthetic
finding.
"""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
policy_id = str(uuid4())
present_user_id = str(uuid4())
missing_user_id = str(uuid4())
entra_client.conditional_access_policies = {
policy_id: _make_policy(
policy_id,
excluded_users=[present_user_id, missing_user_id],
),
}
entra_client.users = {
present_user_id: User(
id=present_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=[],
),
# missing_user_id intentionally absent — exercises the
# `if not user: continue` short-circuit inside the loop.
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
# One finding for the present user; the missing one is skipped.
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"Cannot verify FIDO2 security key registration for break glass account BreakGlass1"
in result[0].status_extended
)
assert "AuditLog.Read.All" in result[0].status_extended
assert result[0].resource == entra_client.users[present_user_id]
assert result[0].resource_name == "BreakGlass1"
assert result[0].resource_id == present_user_id
@@ -11,6 +11,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -53,6 +54,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -95,6 +97,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -153,6 +156,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -191,6 +195,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -248,6 +253,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -286,6 +292,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -324,6 +331,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -383,6 +391,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
with (
mock.patch(
@@ -420,3 +429,125 @@ class Test_entra_users_mfa_capable:
assert result[0].resource == entra_client.users[user_id]
assert result[0].resource_name == "Test User"
assert result[0].resource_id == user_id
def test_user_registration_details_permission_error(self):
"""Test FAIL when there's a permission error reading user registration details."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
entra_users_mfa_capable,
)
user_id = str(uuid4())
entra_client.users = {
user_id: User(
id=user_id,
name="Test User",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=False,
account_enabled=True,
)
}
check = entra_users_mfa_capable()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"Cannot verify MFA capability for user Test User"
in result[0].status_extended
)
assert "AuditLog.Read.All" in result[0].status_extended
assert result[0].resource == entra_client.users[user_id]
assert result[0].resource_name == "Test User"
assert result[0].resource_id == user_id
def test_user_registration_details_permission_error_skips_guest_and_disabled(self):
"""CIS-scope skip (Guest, disabled) still applies on the permission-error path.
With ``user_registration_details_error`` set, only enabled member users
should receive a per-user "Cannot verify MFA capability" FAIL — guests
and disabled members are filtered out before the error branch runs.
"""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
entra_users_mfa_capable,
)
member_id = str(uuid4())
guest_id = str(uuid4())
disabled_member_id = str(uuid4())
entra_client.users = {
member_id: User(
id=member_id,
name="Enabled Member",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=False,
account_enabled=True,
user_type="Member",
),
guest_id: User(
id=guest_id,
name="Guest User",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=False,
account_enabled=True,
user_type="Guest",
),
disabled_member_id: User(
id=disabled_member_id,
name="Disabled Member",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=False,
account_enabled=False,
user_type="Member",
),
}
check = entra_users_mfa_capable()
result = check.execute()
# Only the enabled member should be reported — Guest and
# disabled member are skipped before the error branch.
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"Cannot verify MFA capability for user Enabled Member"
in result[0].status_extended
)
assert "AuditLog.Read.All" in result[0].status_extended
assert result[0].resource == entra_client.users[member_id]
assert result[0].resource_name == "Enabled Member"
assert result[0].resource_id == member_id
@@ -521,10 +521,26 @@ class Test_Entra_Service:
assert len(users) == 6
assert users_builder.get.await_count == 1
assert users_builder.get.await_args.kwargs == {}
# The Graph users.get() call must request accountEnabled, userType and
# onPremisesSyncEnabled via $select. They are not part of the default
# property set, and omitting them causes disabled guest users to leak
# into checks like entra_users_mfa_capable (issue #10921).
request_configuration = users_builder.get.await_args.kwargs[
"request_configuration"
]
assert set(request_configuration.query_parameters.select) == {
"id",
"displayName",
"userType",
"accountEnabled",
"onPremisesSyncEnabled",
}
with_url_mock.assert_called_once_with("next-link")
assert users["user-1"].directory_roles_ids == ["role-template-1"]
assert users["user-6"].directory_roles_ids == ["role-template-1"]
# When Graph does not return accountEnabled (legacy SimpleNamespace
# fixtures) we still honour the EXO PowerShell fallback for backwards
# compatibility.
assert users["user-6"].account_enabled is False
assert users["user-1"].is_mfa_capable is True
assert users["user-2"].is_mfa_capable is False
@@ -532,6 +548,81 @@ class Test_Entra_Service:
assert users["user-6"].authentication_methods == ["mobilePhone"]
assert users["user-2"].authentication_methods == []
def test__get_users_uses_graph_account_enabled_for_disabled_guests(self):
"""Regression test for https://github.com/prowler-cloud/prowler/issues/10921.
Disabled guest users do not appear in EXO's ``Get-User`` output, so the
previous code resolved their ``account_enabled`` from the EXO map,
defaulted it to ``True`` and surfaced them as failing findings in
``entra_users_mfa_capable``. The Graph ``accountEnabled`` value must be
used as the source of truth so disabled guests are excluded.
"""
entra_service = Entra.__new__(Entra)
# Empty EXO map mirrors the production scenario where the disabled guest
# is absent from Get-User results.
entra_service.user_accounts_status = {}
graph_users = [
SimpleNamespace(
id="member-1",
display_name="Member User",
on_premises_sync_enabled=False,
account_enabled=True,
user_type="Member",
),
SimpleNamespace(
id="guest-1",
display_name="Disabled Guest",
on_premises_sync_enabled=False,
account_enabled=False,
user_type="Guest",
),
SimpleNamespace(
id="guest-2",
display_name="Enabled Guest",
on_premises_sync_enabled=False,
account_enabled=True,
user_type="Guest",
),
]
users_response = SimpleNamespace(
value=graph_users,
odata_next_link=None,
)
users_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response),
with_url=MagicMock(),
)
directory_roles_builder = SimpleNamespace(
get=AsyncMock(return_value=SimpleNamespace(value=[])),
by_directory_role_id=MagicMock(),
)
registration_details_builder = SimpleNamespace(
get=AsyncMock(return_value=SimpleNamespace(value=[], odata_next_link=None)),
with_url=MagicMock(),
)
reports_builder = SimpleNamespace(
authentication_methods=SimpleNamespace(
user_registration_details=registration_details_builder
)
)
entra_service.client = SimpleNamespace(
users=users_builder,
directory_roles=directory_roles_builder,
reports=reports_builder,
)
users = asyncio.run(entra_service._get_users())
assert len(users) == 3
assert users["member-1"].account_enabled is True
assert users["member-1"].user_type == "Member"
assert users["guest-1"].account_enabled is False
assert users["guest-1"].user_type == "Guest"
assert users["guest-2"].account_enabled is True
assert users["guest-2"].user_type == "Guest"
def test__get_user_registration_details_handles_pagination(self):
entra_service = Entra.__new__(Entra)
@@ -573,10 +664,11 @@ class Test_Entra_Service:
)
)
registration_details = asyncio.run(
registration_details, error_message = asyncio.run(
entra_service._get_user_registration_details()
)
assert error_message is None
assert registration_details == {
"user-1": {
"is_mfa_capable": True,
@@ -593,3 +685,34 @@ class Test_Entra_Service:
registration_builder.get.assert_awaited()
registration_builder.with_url.assert_called_once_with("next-link")
registration_builder_next.get.assert_awaited()
def test__get_user_registration_details_returns_error_on_permission_denied(self):
"""Test that 403 Authorization_RequestDenied returns an empty dict and
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()
odata_error.error = MainError()
odata_error.error.code = "Authorization_RequestDenied"
registration_builder = SimpleNamespace(get=AsyncMock(side_effect=odata_error))
entra_service = Entra.__new__(Entra)
entra_service.client = SimpleNamespace(
reports=SimpleNamespace(
authentication_methods=SimpleNamespace(
user_registration_details=registration_builder
)
)
)
registration_details, error_message = asyncio.run(
entra_service._get_user_registration_details()
)
assert registration_details == {}
assert error_message is not None
assert "AuditLog.Read.All" in error_message
assert "user registration details" in error_message
+17
View File
@@ -2,6 +2,23 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.26.2] (Prowler 5.26.2)
### 🐞 Fixed
- Finding drawer no longer renders literal backticks around inline code in Risk, Description and Remediation sections [(#11142)](https://github.com/prowler-cloud/prowler/pull/11142)
---
## [1.26.1] (Prowler 5.26.1)
### 🐞 Fixed
- Role form Cancel buttons now return to Roles [(#11125)](https://github.com/prowler-cloud/prowler/pull/11125)
- Shared select dropdowns stay constrained and scrollable inside modals [(#11125)](https://github.com/prowler-cloud/prowler/pull/11125)
---
## [1.26.0] (Prowler v5.26.0)
### 🚀 Added
@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MarkdownContainer } from "./markdown-container";
describe("MarkdownContainer", () => {
it("renders bold and inline code as semantic elements", () => {
render(
<MarkdownContainer>
{"**Bedrock API keys** are evaluated, configured to `never expire`."}
</MarkdownContainer>,
);
const code = screen.getByText("never expire");
expect(code.tagName).toBe("CODE");
expect(screen.getByText("Bedrock API keys").tagName).toBe("STRONG");
});
it("neutralizes the @tailwindcss/typography backtick pseudo-elements on inline code", () => {
const { container } = render(
<MarkdownContainer>{"text `code` text"}</MarkdownContainer>,
);
const wrapper = container.firstElementChild;
expect(wrapper).not.toBeNull();
const className = wrapper?.className ?? "";
// The prose plugin from @tailwindcss/typography adds ::before/::after
// pseudo-elements with literal backticks on every <code> tag. Without
// these overrides the drawer renders `never expire` with visible
// backticks, which is the bug PROWLER-1729 fixes.
expect(className).toMatch(/prose-code:before:content-none/);
expect(className).toMatch(/prose-code:after:content-none/);
});
});
@@ -5,7 +5,7 @@ interface MarkdownContainerProps {
}
export const MarkdownContainer = ({ children }: MarkdownContainerProps) => (
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
<div className="prose prose-sm dark:prose-invert prose-code:before:content-none prose-code:after:content-none max-w-none break-words whitespace-normal">
<ReactMarkdown>{children}</ReactMarkdown>
</div>
);
@@ -1,10 +1,15 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AddRoleForm } from "./add-role-form";
const routerMocks = vi.hoisted(() => ({
push: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useRouter: () => routerMocks,
}));
vi.mock("@/actions/roles/roles", () => ({
@@ -73,6 +78,7 @@ vi.mock("@/components/ui", () => ({
describe("AddRoleForm", () => {
afterEach(() => {
routerMocks.push.mockClear();
vi.unstubAllEnvs();
});
@@ -99,4 +105,16 @@ describe("AddRoleForm", () => {
expect(screen.queryByText("Manage Alerts")).not.toBeInTheDocument();
expect(screen.queryByText("Manage Billing")).not.toBeInTheDocument();
});
it("navigates back to roles when cancel is clicked", async () => {
// Given
const user = userEvent.setup();
render(<AddRoleForm groups={[]} />);
// When
await user.click(screen.getByRole("button", { name: /cancel/i }));
// Then
expect(routerMocks.push).toHaveBeenCalledWith("/roles");
});
});
@@ -252,7 +252,11 @@ export const AddRoleForm = ({
)}
</div>
)}
<FormButtons submitText="Add Role" isDisabled={isLoading} />
<FormButtons
submitText="Add Role"
isDisabled={isLoading}
onCancel={() => router.push("/roles")}
/>
</form>
</Form>
);
@@ -271,7 +271,11 @@ export const EditRoleForm = ({
)}
</div>
)}
<FormButtons submitText="Update Role" isDisabled={isLoading} />
<FormButtons
submitText="Update Role"
isDisabled={isLoading}
onCancel={() => router.push("/roles")}
/>
</form>
</Form>
);
@@ -239,6 +239,46 @@ describe("MultiSelect", () => {
expect(screen.getByRole("dialog")).toHaveClass("max-w-[24rem]");
});
it("keeps long option lists scrollable inside the dropdown", async () => {
// Given
const user = userEvent.setup();
render(
<MultiSelect values={[]} onValuesChange={() => {}}>
<MultiSelectTrigger>
<MultiSelectValue placeholder="Select accounts" />
</MultiSelectTrigger>
<MultiSelectContent search={false}>
{Array.from({ length: 20 }, (_, index) => (
<MultiSelectItem key={index} value={`account-${index}`}>
Account {index}
</MultiSelectItem>
))}
</MultiSelectContent>
</MultiSelect>,
);
// When
await user.click(screen.getByRole("combobox"));
// Then
const list = screen
.getByRole("dialog")
.querySelector('[data-slot="command-list"]');
expect(screen.getByRole("dialog")).toHaveStyle({
maxHeight:
"min(360px, var(--radix-popover-content-available-height, 360px))",
});
expect(list).toHaveClass("minimal-scrollbar");
expect(list).toHaveStyle({
maxHeight:
"min(300px, var(--radix-popover-content-available-height, 300px))",
});
expect(list).toHaveClass("overflow-y-auto");
expect(list).toHaveClass("overscroll-contain");
});
it("keeps the legacy clear-all behavior by default", async () => {
const user = userEvent.setup();
const onValuesChange = vi.fn();
+17 -3
View File
@@ -10,6 +10,7 @@ import {
useEffect,
useRef,
useState,
type WheelEvent,
} from "react";
import { Badge } from "@/components/shadcn/badge/badge";
@@ -49,6 +50,10 @@ type MultiSelectContextType = {
};
const MultiSelectContext = createContext<MultiSelectContextType | null>(null);
const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
event.stopPropagation();
};
export function MultiSelect({
children,
values,
@@ -335,12 +340,16 @@ export function MultiSelectContent({
<PopoverContent
align="start"
data-slot="multiselect-content"
style={{
maxHeight:
"min(360px, var(--radix-popover-content-available-height, 360px))",
}}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 rounded-lg border p-0",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 overflow-hidden rounded-lg border p-0",
widthClasses,
)}
>
<Command {...props} className="rounded-lg">
<Command {...props} className="max-h-[inherit] rounded-lg">
{canSearch ? (
<CommandInput
placeholder={
@@ -354,7 +363,12 @@ export function MultiSelectContent({
)}
<CommandList
ref={listRef}
className="minimal-scrollbar max-h-[300px] overflow-x-hidden overflow-y-auto p-3"
onWheelCapture={stopWheelPropagation}
style={{
maxHeight:
"min(300px, var(--radix-popover-content-available-height, 300px))",
}}
className="minimal-scrollbar overflow-x-hidden overflow-y-auto overscroll-contain p-3"
>
{canSearch && (
<CommandEmpty className="text-bg-button-secondary py-6 text-center text-sm">
@@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./select";
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
writable: true,
configurable: true,
value: () => false,
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
writable: true,
configurable: true,
value: () => {},
});
describe("Select", () => {
it("keeps long option lists scrollable inside the dropdown", async () => {
// Given
const user = userEvent.setup();
render(
<Select defaultValue="option-1">
<SelectTrigger aria-label="Options">
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 20 }, (_, index) => (
<SelectItem key={index} value={`option-${index}`}>
Option {index}
</SelectItem>
))}
</SelectContent>
</Select>,
);
// When
await user.click(screen.getByRole("combobox", { name: /options/i }));
// Then
const viewport = screen
.getByRole("listbox")
.querySelector('[data-slot="select-viewport"]');
expect(screen.getByRole("listbox")).toHaveStyle({
maxHeight: "var(--radix-select-content-available-height)",
});
expect(viewport).toHaveClass("minimal-scrollbar");
expect(viewport).toHaveStyle({
maxHeight:
"min(300px, var(--radix-select-content-available-height, 300px))",
});
expect(viewport).toHaveClass("overflow-y-auto");
expect(viewport).toHaveClass("overscroll-contain");
});
});
+19 -4
View File
@@ -2,10 +2,14 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { ComponentProps } from "react";
import { ComponentProps, type WheelEvent } from "react";
import { cn } from "@/lib/utils";
const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
event.stopPropagation();
};
function Select({
allowDeselect = false,
...props
@@ -83,6 +87,7 @@ function SelectContent({
position = "popper",
align = "start",
width = "default",
style,
...props
}: ComponentProps<typeof SelectPrimitive.Content> & {
width?: "default" | "wide";
@@ -97,22 +102,32 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-border-input-primary bg-bg-input-primary relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-lg border",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
widthClasses,
className,
)}
style={{
maxHeight: "var(--radix-select-content-available-height)",
...style,
}}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-slot="select-viewport"
onWheelCapture={stopWheelPropagation}
style={{
maxHeight:
"min(300px, var(--radix-select-content-available-height, 300px))",
}}
className={cn(
"flex flex-col gap-1 p-3",
"minimal-scrollbar flex flex-col gap-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
"w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}