mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-13 15:50:55 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ec493ec5c | |||
| 85c1b85852 | |||
| 8f50c6d684 | |||
| 1fb6c6a0f0 | |||
| fc3a25d7a8 | |||
| 1a56087ea0 | |||
| 2fdc480beb | |||
| 8bfc1d85f5 | |||
| 57501e1864 | |||
| 02a83adfd4 | |||
| 98a1bca403 | |||
| 8aade7f024 | |||
| 43b50c4d6f |
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.27.0
|
||||
version: 1.27.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+9
@@ -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"]]:
|
||||
"""
|
||||
|
||||
+11
-1
@@ -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
@@ -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"
|
||||
|
||||
+124
@@ -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
|
||||
|
||||
+131
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user