mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-14 16:25:13 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cd2ffbca2 | |||
| a646c68308 | |||
| 62ad4e5b9f | |||
| ce8f6037c1 | |||
| b49e49f543 | |||
| 4cf2207d58 | |||
| 080bc174fa | |||
| 6fac51047c | |||
| 15a5527910 |
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.26.2
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.26.0
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
+3
-18
@@ -2,28 +2,13 @@
|
||||
|
||||
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)
|
||||
## [1.27.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
- New `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
- ASD Essential Eight (AWS) compliance framework support [(#10982)](https://github.com/prowler-cloud/prowler/pull/10982)
|
||||
- `scan-reset-ephemeral-resources` post-scan task zeroes `failed_findings_count` for resources missing from the latest full-scope scan, keeping ephemeral resources from polluting the Resources page sort [(#10929)](https://github.com/prowler-cloud/prowler/pull/10929)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
|
||||
Generated
+3
-3
@@ -6754,8 +6754,8 @@ uuid6 = "2024.7.10"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "v5.26"
|
||||
resolved_reference = "02cdcb29dbcd8eb5ed442c1cd03830000324fb0f"
|
||||
reference = "master"
|
||||
resolved_reference = "16798e293da365965120961e6539e3a9756564f9"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
@@ -9424,4 +9424,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.13"
|
||||
content-hash = "24f7a92f6c72a8207ab15f75c813a5a244c018afb0a582a5abf8c96e2c7faf12"
|
||||
content-hash = "a3ab982d11a87d951ff15694d2ca7fd51f1f51a451abb0baa067ccf6966367a8"
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ dependencies = [
|
||||
"defusedxml==0.7.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==5.3.2",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.26",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -50,7 +50,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.27.2"
|
||||
version = "1.27.0"
|
||||
|
||||
[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 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'}})
|
||||
// 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'}})
|
||||
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 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'}})
|
||||
// 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'}})
|
||||
|
||||
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.2
|
||||
version: 1.27.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -17,7 +16,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, states
|
||||
from celery import chain
|
||||
from celery.result import AsyncResult
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.env import env
|
||||
@@ -61,7 +60,6 @@ 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 (
|
||||
@@ -424,7 +422,7 @@ class SchemaView(SpectacularAPIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
spectacular_settings.TITLE = "Prowler API"
|
||||
spectacular_settings.VERSION = "1.27.2"
|
||||
spectacular_settings.VERSION = "1.27.0"
|
||||
spectacular_settings.DESCRIPTION = (
|
||||
"Prowler API specification.\n\nThis file is auto-generated."
|
||||
)
|
||||
@@ -2536,45 +2534,28 @@ 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()
|
||||
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),
|
||||
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")
|
||||
},
|
||||
)
|
||||
|
||||
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},
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
prowler_task = Task.objects.get(id=task.id)
|
||||
scan.task_id = task.id
|
||||
scan.save(update_fields=["task_id"])
|
||||
|
||||
self.response_serializer_class = TaskSerializer
|
||||
output_serializer = self.get_serializer(prowler_task)
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
export const VersionBadge = ({ version }) => {
|
||||
return (
|
||||
<a
|
||||
href={`https://github.com/prowler-cloud/prowler/releases/tag/${version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="version-badge-link"
|
||||
>
|
||||
<span className="version-badge-container">
|
||||
<span className="version-badge">
|
||||
<span className="version-badge-label">Added in:</span>
|
||||
<span className="version-badge-version">{version}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<code className="version-badge-container">
|
||||
<p className="version-badge">
|
||||
<span className="version-badge-label">Added in:</span>
|
||||
<code className="version-badge-version">{version}</code>
|
||||
</p>
|
||||
</code>
|
||||
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
/* Version Badge Styling */
|
||||
.version-badge-link,
|
||||
.version-badge-link:hover,
|
||||
.version-badge-link:focus,
|
||||
.version-badge-link:active,
|
||||
.version-badge-link:visited {
|
||||
display: inline-block;
|
||||
text-decoration: none !important;
|
||||
background-image: none !important;
|
||||
border-bottom: none !important;
|
||||
color: inherit;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.version-badge-link:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.version-badge-container {
|
||||
display: inline-block;
|
||||
margin: 0 0 1rem 0;
|
||||
|
||||
+6
-13
@@ -2,30 +2,23 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.26.2] (Prowler UNRELEASED)
|
||||
## [5.27.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
### 🚀 Added
|
||||
|
||||
- `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)
|
||||
- `entra_pim_only_management` check for m365 provider [(#10848)](https://github.com/prowler-cloud/prowler/pull/10848)
|
||||
- `entra_pim_stale_sign_in_alert` check for m365 provider [(#10798)](https://github.com/prowler-cloud/prowler/pull/10798)
|
||||
|
||||
---
|
||||
|
||||
## [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)
|
||||
## [5.26.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844)
|
||||
- Universal compliance with OCSF support [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
|
||||
- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808)
|
||||
- Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
|
||||
- Update Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
|
||||
- `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878)
|
||||
- 8 Gmail attachment safety and spoofing protection checks for Google Workspace provider using the Cloud Identity Policy API [(#10980)](https://github.com/prowler-cloud/prowler/pull/10980)
|
||||
- `bedrock_prompt_encrypted_with_cmk` check for AWS provider [(#10905)](https://github.com/prowler-cloud/prowler/pull/10905)
|
||||
|
||||
@@ -1502,7 +1502,10 @@
|
||||
{
|
||||
"Id": "5.3.1",
|
||||
"Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Organizations should remove permanent members from privileged Office 365 roles and instead make them eligible, through a JIT activation workflow.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"entra_pim_only_management",
|
||||
"entra_pim_stale_sign_in_alert"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "5 Microsoft Entra admin center",
|
||||
@@ -1544,7 +1547,9 @@
|
||||
{
|
||||
"Id": "5.3.3",
|
||||
"Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. These reviews can be scheduled to recur regularly, with flexible options for delegating the task of reviewing membership to different members of the organization.Ensure `Access reviews` for high privileged Entra ID roles are done `monthly` or more frequently. These reviews should include **at a minimum** the roles listed below:- Global Administrator- Exchange Administrator- SharePoint Administrator- Teams Administrator- Security Administrator**Note:** An access review is created for each role selected after completing the process.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"entra_pim_stale_sign_in_alert"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "5 Microsoft Entra admin center",
|
||||
|
||||
@@ -1803,7 +1803,10 @@
|
||||
{
|
||||
"Id": "5.3.1",
|
||||
"Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Organizations should remove permanent members from privileged Office 365 roles and instead make them eligible, through a JIT activation workflow. Ensure 'Privileged Identity Management' is used to manage roles.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"entra_pim_only_management",
|
||||
"entra_pim_stale_sign_in_alert"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "5 Microsoft Entra admin center",
|
||||
@@ -1845,7 +1848,9 @@
|
||||
{
|
||||
"Id": "5.3.3",
|
||||
"Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. Ensure 'Access reviews' for privileged roles are configured to be done monthly or more frequently.",
|
||||
"Checks": [],
|
||||
"Checks": [
|
||||
"entra_pim_stale_sign_in_alert"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
"Section": "5 Microsoft Entra admin center",
|
||||
|
||||
@@ -281,6 +281,8 @@
|
||||
"Checks": [
|
||||
"entra_admin_portals_access_restriction",
|
||||
"entra_app_registration_no_unused_privileged_permissions",
|
||||
"entra_pim_only_management",
|
||||
"entra_pim_stale_sign_in_alert",
|
||||
"entra_policy_guest_users_access_restrictions",
|
||||
"sharepoint_external_sharing_managed",
|
||||
"sharepoint_external_sharing_restricted",
|
||||
@@ -672,6 +674,8 @@
|
||||
"entra_admin_users_sign_in_frequency_enabled",
|
||||
"entra_break_glass_account_fido2_security_key_registered",
|
||||
"entra_app_registration_no_unused_privileged_permissions",
|
||||
"entra_pim_only_management",
|
||||
"entra_pim_stale_sign_in_alert",
|
||||
"entra_policy_ensure_default_user_cannot_create_tenants",
|
||||
"entra_policy_guest_invite_only_for_admin_roles",
|
||||
"entra_seamless_sso_disabled"
|
||||
|
||||
@@ -48,7 +48,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.26.2"
|
||||
prowler_version = "5.26.0"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
-9
@@ -85,15 +85,6 @@ 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
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "entra_pim_only_management",
|
||||
"CheckTitle": "PIM-only management ensures privileged role assignments are governed and auditable",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "Checks whether all privileged role assignments in Microsoft Entra ID are managed through Privileged Identity Management (PIM). Detects active PIM alerts for role assignments made directly, bypassing PIM governance controls such as approval workflows, justification requirements, and time-bound access.",
|
||||
"Risk": "Role assignments made outside PIM bypass governance controls, removing audit trails, approval workflows, and time-bound access. This may indicate an active attack or privilege escalation, as adversaries can silently grant persistent administrative access without detection, impacting confidentiality, integrity, and availability.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure",
|
||||
"https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-configure-security-alerts"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center > Identity Governance > Privileged Identity Management > Microsoft Entra roles > Alerts\n2. Review the 'Roles are being assigned outside of Privileged Identity Management' alert\n3. For each affected assignment, click the alert to view details\n4. Remove the direct role assignments and re-create them as PIM-eligible assignments\n5. Ensure all future role assignments are made through PIM",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable **PIM-only management** by ensuring all privileged role assignments are made through Privileged Identity Management. Remove any direct (permanent) role assignments that bypass PIM and replace them with eligible assignments. Configure PIM alerts to monitor for assignments made outside PIM and review them regularly.",
|
||||
"Url": "https://hub.prowler.com/check/entra_pim_only_management"
|
||||
}
|
||||
},
|
||||
"Categories": ["identity-access"],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
"""Check for role assignments made outside of Privileged Identity Management (PIM)."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.entra.entra_client import entra_client
|
||||
|
||||
# Substring match against alert_definition_id. Microsoft Graph PIM exposes this
|
||||
# alert under names such as ``RolesAssignedOutsidePimAlertDefinition`` (v1.0)
|
||||
# and ``DirectoryRole_<scope>_RolesAssignedOutsidePimAlert`` (legacy beta).
|
||||
# Matching on the stable suffix keeps the check working regardless of which
|
||||
# format the API returns for the tenant being scanned.
|
||||
ROLES_ASSIGNED_OUTSIDE_PIM_ALERT_SUBSTRING = "RolesAssignedOutsidePim"
|
||||
|
||||
|
||||
class entra_pim_only_management(Check):
|
||||
"""Ensure all privileged role assignments are managed through PIM.
|
||||
|
||||
PIM raises ``RolesAssignedOutsidePim`` when a privileged directory role is
|
||||
granted to a principal directly, bypassing PIM's approval workflows,
|
||||
justification requirements, and time-bound access. This check inspects the
|
||||
PIM alert feed to detect that condition.
|
||||
|
||||
- PASS: The alert exists and reports no affected items.
|
||||
- FAIL: The alert is active and has one or more affected items.
|
||||
- MANUAL: PIM alerts are not available for the tenant (no Microsoft Entra
|
||||
ID P2 license, alert disabled, or insufficient permissions to read PIM).
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the PIM-only management check.
|
||||
|
||||
Returns:
|
||||
list[CheckReportM365]: One finding per tenant, or an empty list if
|
||||
no organization is exposed by the provider.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
if not entra_client.organizations:
|
||||
return findings
|
||||
|
||||
organization = entra_client.organizations[0]
|
||||
|
||||
matching_alert = next(
|
||||
(
|
||||
alert
|
||||
for alert in entra_client.pim_alerts.values()
|
||||
if ROLES_ASSIGNED_OUTSIDE_PIM_ALERT_SUBSTRING
|
||||
in (alert.alert_definition_id or "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if matching_alert is None:
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=organization,
|
||||
resource_id=organization.id,
|
||||
resource_name=organization.name,
|
||||
)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
"PIM 'roles assigned outside of PIM' alert is not available. "
|
||||
"This can happen when the tenant lacks Microsoft Entra ID P2, "
|
||||
"the alert is disabled, or the running credentials cannot read "
|
||||
"PIM alerts. Review the alert configuration in the Entra admin "
|
||||
"center under Identity Governance > Privileged Identity "
|
||||
"Management > Alerts."
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=matching_alert,
|
||||
resource_id=matching_alert.id,
|
||||
resource_name="PIM Roles Assigned Outside Of PIM Alert",
|
||||
)
|
||||
|
||||
if matching_alert.is_active and matching_alert.number_of_affected_items > 0:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"PIM detected {matching_alert.number_of_affected_items} "
|
||||
"privileged role assignment(s) made outside of PIM, bypassing "
|
||||
"governance controls."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
"All privileged role assignments are managed through "
|
||||
"Privileged Identity Management (PIM)."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "entra_pim_stale_sign_in_alert",
|
||||
"CheckTitle": "PIM stale sign-in alert detects unused privileged accounts that may be compromised",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "governance",
|
||||
"Description": "Microsoft Entra PIM monitors privileged role assignments and raises a **stale sign-in alert** when accounts have not authenticated within a configured period (default 30 days).\n\n*Stale privileged accounts indicate roles that may be over-provisioned or abandoned.*",
|
||||
"Risk": "Stale accounts retaining **privileged roles** expand the attack surface by providing dormant credentials that attackers can exploit undetected. Compromised stale accounts enable **privilege escalation** and **lateral movement** without triggering normal user activity alerts, weakening confidentiality and integrity.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-configure-security-alerts",
|
||||
"https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-security-alerts"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center\n2. Navigate to Identity governance > Privileged Identity Management > Alerts\n3. Review the 'Potential stale accounts in a privileged role' alert\n4. For each affected account, remove unnecessary role assignments or confirm the account is still required\n5. Investigate whether stale accounts show signs of compromise",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Apply **least privilege** by removing privileged role assignments from accounts that no longer require them. Implement **just-in-time access** via PIM eligible assignments instead of permanent roles. Establish periodic **access reviews** to detect and remediate stale privileged accounts before they can be exploited.",
|
||||
"Url": "https://hub.prowler.com/check/entra_pim_stale_sign_in_alert"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"e3"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Requires Microsoft Entra ID P2 license and RoleManagement.Read.All permission. The stale sign-in threshold is configurable in PIM alert settings (default: 30 days)."
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.entra.entra_client import entra_client
|
||||
|
||||
STALE_SIGN_IN_ALERT_DEFINITION_ID = "DirectoryRole_StaleSignInAlert"
|
||||
|
||||
|
||||
class entra_pim_stale_sign_in_alert(Check):
|
||||
"""Check if there are stale accounts in privileged roles detected by PIM.
|
||||
|
||||
This check verifies that Privileged Identity Management (PIM) does not
|
||||
report any stale sign-in alerts for users with privileged role assignments.
|
||||
A stale account is one that has not signed in within a configured period
|
||||
(default 30 days) while still retaining a privileged directory role.
|
||||
|
||||
Stale privileged accounts represent a significant security risk because
|
||||
unused credentials in elevated roles can be exploited by attackers without
|
||||
detection.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the PIM stale sign-in alert check.
|
||||
|
||||
Retrieves the PIM stale sign-in alert from the Entra client and generates
|
||||
a report indicating whether stale accounts exist in privileged roles.
|
||||
|
||||
Returns:
|
||||
List[CheckReportM365]: A list containing the report object with the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
stale_alert = entra_client.pim_alerts.get(STALE_SIGN_IN_ALERT_DEFINITION_ID)
|
||||
|
||||
if stale_alert:
|
||||
report = CheckReportM365(
|
||||
self.metadata(),
|
||||
resource=stale_alert,
|
||||
resource_id=stale_alert.id,
|
||||
resource_name="PIM Stale Sign-In Alert",
|
||||
)
|
||||
|
||||
if stale_alert.is_active and stale_alert.number_of_affected_items > 0:
|
||||
affected_users = ", ".join(
|
||||
incident.assignee_display_name or incident.assignee_id
|
||||
for incident in stale_alert.affected_items[:5]
|
||||
)
|
||||
suffix = (
|
||||
f" and {stale_alert.number_of_affected_items - 5} more"
|
||||
if stale_alert.number_of_affected_items > 5
|
||||
else ""
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"PIM detected {stale_alert.number_of_affected_items} "
|
||||
f"stale account(s) in privileged roles: {affected_users}{suffix}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = "PIM stale sign-in alert reports no stale accounts in privileged roles."
|
||||
|
||||
findings.append(report)
|
||||
elif entra_client.organizations:
|
||||
organization = entra_client.organizations[0]
|
||||
report = CheckReportM365(
|
||||
self.metadata(),
|
||||
resource=organization,
|
||||
resource_id=organization.id,
|
||||
resource_name=organization.name,
|
||||
)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
"PIM stale sign-in alert is not available. This can happen when "
|
||||
"the tenant lacks Microsoft Entra ID P2, the alert is disabled, "
|
||||
"or the running credentials cannot read PIM alerts. Review the "
|
||||
"alert configuration in the Entra admin center under Identity "
|
||||
"Governance > Privileged Identity Management > Alerts."
|
||||
)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -2,15 +2,15 @@ import asyncio
|
||||
import json
|
||||
from asyncio import gather
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from kiota_abstractions.base_request_configuration import RequestConfiguration
|
||||
from kiota_abstractions.method import Method
|
||||
from kiota_abstractions.request_information import RequestInformation
|
||||
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
|
||||
@@ -25,7 +25,7 @@ class Entra(M365Service):
|
||||
This class provides methods to retrieve and manage Microsoft Entra ID
|
||||
security policies and configurations, including authorization policies,
|
||||
conditional access policies, admin consent policies, groups, organizations,
|
||||
users, and OAuth application data from Defender XDR.
|
||||
users, OAuth application data from Defender XDR, and PIM alerts.
|
||||
|
||||
Attributes:
|
||||
tenant_domain (str): The tenant domain.
|
||||
@@ -38,6 +38,7 @@ class Entra(M365Service):
|
||||
user_accounts_status (dict): Dictionary of user account statuses.
|
||||
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
|
||||
authentication_method_configurations (dict): Dictionary of authentication method configurations.
|
||||
pim_alerts (dict): Dictionary of PIM alerts keyed by alert definition ID.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: M365Provider):
|
||||
@@ -73,7 +74,6 @@ 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(),
|
||||
@@ -86,6 +86,7 @@ class Entra(M365Service):
|
||||
self._get_oauth_apps(),
|
||||
self._get_directory_sync_settings(),
|
||||
self._get_authentication_method_configurations(),
|
||||
self._get_pim_alerts(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -101,6 +102,7 @@ class Entra(M365Service):
|
||||
self.authentication_method_configurations: Dict[
|
||||
str, AuthenticationMethodConfiguration
|
||||
] = attributes[9]
|
||||
self.pim_alerts: Dict[str, PimAlert] = attributes[10]
|
||||
self.user_accounts_status = {}
|
||||
|
||||
if created_loop:
|
||||
@@ -809,29 +811,7 @@ class Entra(M365Service):
|
||||
logger.info("Entra - Getting users...")
|
||||
users = {}
|
||||
try:
|
||||
# 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,
|
||||
)
|
||||
users_response = await self.client.users.get()
|
||||
directory_roles = await self.client.directory_roles.get()
|
||||
|
||||
async def fetch_role_members(directory_role):
|
||||
@@ -850,26 +830,11 @@ class Entra(M365Service):
|
||||
for member in members:
|
||||
user_roles_map.setdefault(member.id, []).append(role_template_id)
|
||||
|
||||
registration_details, self.user_registration_details_error = (
|
||||
await self._get_user_registration_details()
|
||||
)
|
||||
registration_details = 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,
|
||||
@@ -878,7 +843,9 @@ class Entra(M365Service):
|
||||
),
|
||||
directory_roles_ids=user_roles_map.get(user.id, []),
|
||||
is_mfa_capable=reg_info.get("is_mfa_capable", False),
|
||||
account_enabled=account_enabled,
|
||||
account_enabled=not self.user_accounts_status.get(
|
||||
user.id, {}
|
||||
).get("AccountDisabled", False),
|
||||
authentication_methods=reg_info.get(
|
||||
"authentication_methods", []
|
||||
),
|
||||
@@ -895,24 +862,18 @@ class Entra(M365Service):
|
||||
)
|
||||
return users
|
||||
|
||||
async def _get_user_registration_details(
|
||||
self,
|
||||
) -> Tuple[Dict[str, Dict[str, Any]], Optional[str]]:
|
||||
async def _get_user_registration_details(self):
|
||||
"""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:
|
||||
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.
|
||||
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).
|
||||
"""
|
||||
registration_details = {}
|
||||
error_message = None
|
||||
try:
|
||||
registration_builder = (
|
||||
self.client.reports.authentication_methods.user_registration_details
|
||||
@@ -937,25 +898,16 @@ class Entra(M365Service):
|
||||
next_link
|
||||
).get()
|
||||
|
||||
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:
|
||||
except Exception as error:
|
||||
if (
|
||||
error.__class__.__name__ == "ODataError"
|
||||
and error.__dict__.get("response_status_code", None) == 403
|
||||
):
|
||||
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, error_message
|
||||
return registration_details
|
||||
|
||||
async def _get_oauth_apps(self) -> Optional[Dict[str, "OAuthApp"]]:
|
||||
"""
|
||||
@@ -1108,6 +1060,72 @@ OAuthAppInfo
|
||||
)
|
||||
return authentication_method_configurations
|
||||
|
||||
async def _get_pim_alerts(self):
|
||||
"""Retrieve Privileged Identity Management (PIM) role management alerts.
|
||||
|
||||
Fetches unified role management alerts from the Microsoft Graph API to
|
||||
identify security issues such as stale accounts in privileged roles.
|
||||
Uses a raw HTTP request since the SDK does not expose this endpoint natively.
|
||||
|
||||
Returns:
|
||||
Dict[str, PimAlert]: Dictionary of PIM alerts keyed by alert definition ID.
|
||||
"""
|
||||
logger.info("Entra - Getting PIM alerts...")
|
||||
pim_alerts = {}
|
||||
try:
|
||||
request_info = RequestInformation()
|
||||
request_info.http_method = Method.GET
|
||||
request_info.url = "https://graph.microsoft.com/v1.0/identityGovernance/roleManagement/alerts/alerts?$expand=alertIncidents"
|
||||
response = await self.client.request_adapter.send_primitive_async(
|
||||
request_info, "bytes", {}
|
||||
)
|
||||
if response:
|
||||
data = json.loads(response)
|
||||
for alert in data.get("value", []):
|
||||
alert_definition_id = alert.get("alertDefinitionId", "")
|
||||
incidents = alert.get("alertIncidents", [])
|
||||
affected_items = []
|
||||
for incident in incidents:
|
||||
affected_items.append(
|
||||
PimAlertIncident(
|
||||
assignee_display_name=incident.get(
|
||||
"assigneeDisplayName", ""
|
||||
),
|
||||
assignee_id=incident.get("assigneeId", ""),
|
||||
role_display_name=incident.get("roleDisplayName", ""),
|
||||
last_sign_in_date_time=incident.get(
|
||||
"lastSignInDateTime", ""
|
||||
),
|
||||
)
|
||||
)
|
||||
pim_alerts[alert_definition_id] = PimAlert(
|
||||
id=alert.get("id", ""),
|
||||
alert_definition_id=alert_definition_id,
|
||||
scope_id=alert.get("scopeId", "/"),
|
||||
scope_type=alert.get("scopeType", ""),
|
||||
is_active=alert.get("isActive", False),
|
||||
number_of_affected_items=alert.get("numberOfAffectedItems", 0),
|
||||
incident_count=alert.get("incidentCount", 0),
|
||||
affected_items=affected_items,
|
||||
)
|
||||
except ODataError as error:
|
||||
error_code = getattr(error.error, "code", None) if error.error else None
|
||||
if error_code == "Authorization_RequestDenied":
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: "
|
||||
"Insufficient privileges to read PIM alerts. "
|
||||
"Required permission: RoleManagement.Read.All or RoleManagement.ReadWrite.Directory"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return pim_alerts
|
||||
|
||||
|
||||
class ConditionalAccessPolicyState(Enum):
|
||||
ENABLED = "enabled"
|
||||
@@ -1539,3 +1557,43 @@ class OAuthApp(BaseModel):
|
||||
is_admin_consented: bool = False
|
||||
last_used_time: Optional[str] = None
|
||||
app_origin: str = ""
|
||||
|
||||
|
||||
class PimAlertIncident(BaseModel):
|
||||
"""Model representing an incident (affected resource) within a PIM alert.
|
||||
|
||||
Attributes:
|
||||
assignee_display_name: The display name of the user with a stale sign-in.
|
||||
assignee_id: The unique identifier of the affected user.
|
||||
role_display_name: The privileged role assigned to the user.
|
||||
last_sign_in_date_time: The last sign-in date for the user (ISO 8601 format).
|
||||
"""
|
||||
|
||||
assignee_display_name: str = ""
|
||||
assignee_id: str = ""
|
||||
role_display_name: str = ""
|
||||
last_sign_in_date_time: str = ""
|
||||
|
||||
|
||||
class PimAlert(BaseModel):
|
||||
"""Model representing a Privileged Identity Management (PIM) alert.
|
||||
|
||||
Attributes:
|
||||
id: The unique identifier of the alert.
|
||||
alert_definition_id: The alert type identifier (e.g., 'DirectoryRole_StaleSignInAlert').
|
||||
scope_id: The scope of the alert (typically '/' for tenant-wide).
|
||||
scope_type: The scope type (e.g., 'DirectoryRole').
|
||||
is_active: Whether the alert is currently active.
|
||||
number_of_affected_items: Count of resources affected by the alert.
|
||||
incident_count: Number of incidents reported for the alert.
|
||||
affected_items: List of affected resources (incidents).
|
||||
"""
|
||||
|
||||
id: str
|
||||
alert_definition_id: str
|
||||
scope_id: str = "/"
|
||||
scope_type: str = ""
|
||||
is_active: bool = False
|
||||
number_of_affected_items: int = 0
|
||||
incident_count: int = 0
|
||||
affected_items: List[PimAlertIncident] = []
|
||||
|
||||
+1
-11
@@ -13,10 +13,6 @@ 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]:
|
||||
@@ -46,13 +42,7 @@ class entra_users_mfa_capable(Check):
|
||||
resource_id=user.id,
|
||||
)
|
||||
|
||||
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:
|
||||
if 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.2"
|
||||
version = "5.26.0"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
-124
@@ -67,7 +67,6 @@ 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(
|
||||
@@ -105,7 +104,6 @@ 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(
|
||||
@@ -144,7 +142,6 @@ 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(
|
||||
@@ -181,7 +178,6 @@ 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(
|
||||
@@ -232,7 +228,6 @@ 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(
|
||||
@@ -280,7 +275,6 @@ 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(
|
||||
@@ -327,7 +321,6 @@ 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(
|
||||
@@ -375,7 +368,6 @@ 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(
|
||||
@@ -430,7 +422,6 @@ 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(
|
||||
@@ -466,7 +457,6 @@ 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(
|
||||
@@ -510,117 +500,3 @@ 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
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
Organization,
|
||||
PimAlert,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
|
||||
|
||||
|
||||
CONTOSO_ORG = Organization(
|
||||
id="org-001",
|
||||
name="Contoso",
|
||||
on_premises_sync_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
class Test_entra_pim_only_management:
|
||||
def test_no_roles_assigned_outside_pim(self):
|
||||
"""PASS when the RolesAssignedOutsidePim alert has zero affected items."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_only_management.entra_pim_only_management.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
|
||||
entra_pim_only_management,
|
||||
)
|
||||
|
||||
entra_client.organizations = [CONTOSO_ORG]
|
||||
entra_client.pim_alerts = {
|
||||
"DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert": PimAlert(
|
||||
id="alert-1",
|
||||
alert_definition_id="DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert",
|
||||
is_active=False,
|
||||
number_of_affected_items=0,
|
||||
),
|
||||
}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_pim_only_management()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
"managed through Privileged Identity Management"
|
||||
in result[0].status_extended
|
||||
)
|
||||
assert result[0].resource_id == "alert-1"
|
||||
assert result[0].resource_name == "PIM Roles Assigned Outside Of PIM Alert"
|
||||
|
||||
def test_roles_assigned_outside_pim(self):
|
||||
"""FAIL when the RolesAssignedOutsidePim alert is active with affected items."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_only_management.entra_pim_only_management.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
|
||||
entra_pim_only_management,
|
||||
)
|
||||
|
||||
entra_client.organizations = [CONTOSO_ORG]
|
||||
entra_client.pim_alerts = {
|
||||
"DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert": PimAlert(
|
||||
id="alert-1",
|
||||
alert_definition_id="DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert",
|
||||
is_active=True,
|
||||
number_of_affected_items=3,
|
||||
),
|
||||
}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_pim_only_management()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "3 privileged role assignment(s)" in result[0].status_extended
|
||||
assert "outside of PIM" in result[0].status_extended
|
||||
assert result[0].resource_id == "alert-1"
|
||||
|
||||
def test_no_pim_alerts(self):
|
||||
"""MANUAL when there are no PIM alerts (likely no P2 license)."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_only_management.entra_pim_only_management.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
|
||||
entra_pim_only_management,
|
||||
)
|
||||
|
||||
entra_client.organizations = [CONTOSO_ORG]
|
||||
entra_client.pim_alerts = {}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_pim_only_management()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "not available" in result[0].status_extended
|
||||
assert "P2" in result[0].status_extended
|
||||
assert result[0].resource_id == "org-001"
|
||||
assert result[0].resource_name == "Contoso"
|
||||
|
||||
def test_other_pim_alerts_only(self):
|
||||
"""MANUAL when PIM alerts exist but none match the RolesAssignedOutsidePim definition."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_only_management.entra_pim_only_management.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
|
||||
entra_pim_only_management,
|
||||
)
|
||||
|
||||
entra_client.organizations = [CONTOSO_ORG]
|
||||
entra_client.pim_alerts = {
|
||||
"TooManyGlobalAdminsAssignedToTenantAlert": PimAlert(
|
||||
id="alert-other",
|
||||
alert_definition_id="TooManyGlobalAdminsAssignedToTenantAlert",
|
||||
is_active=True,
|
||||
number_of_affected_items=5,
|
||||
),
|
||||
}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_pim_only_management()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "not available" in result[0].status_extended
|
||||
|
||||
def test_inactive_alert_with_lingering_affected_items(self):
|
||||
"""PASS when the RolesAssignedOutsidePim alert reports counts but is not active."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_only_management.entra_pim_only_management.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
|
||||
entra_pim_only_management,
|
||||
)
|
||||
|
||||
entra_client.organizations = [CONTOSO_ORG]
|
||||
entra_client.pim_alerts = {
|
||||
"DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert": PimAlert(
|
||||
id="alert-1",
|
||||
alert_definition_id="DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert",
|
||||
is_active=False,
|
||||
number_of_affected_items=3,
|
||||
),
|
||||
}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_pim_only_management()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
"managed through Privileged Identity Management"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_no_organizations_returns_empty(self):
|
||||
"""No findings when the provider returns no organizations."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_only_management.entra_pim_only_management.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
|
||||
entra_pim_only_management,
|
||||
)
|
||||
|
||||
entra_client.organizations = []
|
||||
entra_client.pim_alerts = {}
|
||||
entra_client.tenant_domain = DOMAIN
|
||||
|
||||
check = entra_pim_only_management()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
from unittest import mock
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
Organization,
|
||||
PimAlert,
|
||||
PimAlertIncident,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
|
||||
|
||||
|
||||
class Test_entra_pim_stale_sign_in_alert:
|
||||
def test_no_stale_accounts(self):
|
||||
"""PASS: PIM stale sign-in alert exists with no affected items."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
|
||||
entra_pim_stale_sign_in_alert,
|
||||
)
|
||||
|
||||
entra_client.pim_alerts = {
|
||||
"DirectoryRole_StaleSignInAlert": PimAlert(
|
||||
id="alert-001",
|
||||
alert_definition_id="DirectoryRole_StaleSignInAlert",
|
||||
scope_id="/",
|
||||
scope_type="DirectoryRole",
|
||||
is_active=True,
|
||||
number_of_affected_items=0,
|
||||
incident_count=0,
|
||||
affected_items=[],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_pim_stale_sign_in_alert()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== "PIM stale sign-in alert reports no stale accounts in privileged roles."
|
||||
)
|
||||
assert result[0].resource_id == "alert-001"
|
||||
assert result[0].resource_name == "PIM Stale Sign-In Alert"
|
||||
assert result[0].location == "global"
|
||||
|
||||
def test_stale_accounts_detected(self):
|
||||
"""FAIL: PIM stale sign-in alert has affected items."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
|
||||
entra_pim_stale_sign_in_alert,
|
||||
)
|
||||
|
||||
entra_client.pim_alerts = {
|
||||
"DirectoryRole_StaleSignInAlert": PimAlert(
|
||||
id="alert-001",
|
||||
alert_definition_id="DirectoryRole_StaleSignInAlert",
|
||||
scope_id="/",
|
||||
scope_type="DirectoryRole",
|
||||
is_active=True,
|
||||
number_of_affected_items=2,
|
||||
incident_count=2,
|
||||
affected_items=[
|
||||
PimAlertIncident(
|
||||
assignee_display_name="John Doe",
|
||||
assignee_id="user-001",
|
||||
role_display_name="Global Administrator",
|
||||
last_sign_in_date_time="2025-01-01T00:00:00Z",
|
||||
),
|
||||
PimAlertIncident(
|
||||
assignee_display_name="Jane Smith",
|
||||
assignee_id="user-002",
|
||||
role_display_name="Security Administrator",
|
||||
last_sign_in_date_time="2025-02-01T00:00:00Z",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_pim_stale_sign_in_alert()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "2 stale account(s)" in result[0].status_extended
|
||||
assert "John Doe" in result[0].status_extended
|
||||
assert "Jane Smith" in result[0].status_extended
|
||||
assert result[0].resource_id == "alert-001"
|
||||
assert result[0].resource_name == "PIM Stale Sign-In Alert"
|
||||
assert result[0].location == "global"
|
||||
|
||||
def test_stale_accounts_more_than_five(self):
|
||||
"""FAIL: PIM stale sign-in alert with more than 5 affected items truncates display."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
|
||||
entra_pim_stale_sign_in_alert,
|
||||
)
|
||||
|
||||
affected_items = [
|
||||
PimAlertIncident(
|
||||
assignee_display_name=f"User {i}",
|
||||
assignee_id=f"user-{i:03d}",
|
||||
role_display_name="Global Administrator",
|
||||
last_sign_in_date_time="2025-01-01T00:00:00Z",
|
||||
)
|
||||
for i in range(7)
|
||||
]
|
||||
|
||||
entra_client.pim_alerts = {
|
||||
"DirectoryRole_StaleSignInAlert": PimAlert(
|
||||
id="alert-001",
|
||||
alert_definition_id="DirectoryRole_StaleSignInAlert",
|
||||
scope_id="/",
|
||||
scope_type="DirectoryRole",
|
||||
is_active=True,
|
||||
number_of_affected_items=7,
|
||||
incident_count=7,
|
||||
affected_items=affected_items,
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_pim_stale_sign_in_alert()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "7 stale account(s)" in result[0].status_extended
|
||||
assert "and 2 more" in result[0].status_extended
|
||||
assert result[0].resource_id == "alert-001"
|
||||
|
||||
def test_alert_not_configured(self):
|
||||
"""MANUAL: PIM stale sign-in alert is not available."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
|
||||
entra_pim_stale_sign_in_alert,
|
||||
)
|
||||
|
||||
entra_client.pim_alerts = {}
|
||||
entra_client.organizations = [
|
||||
Organization(
|
||||
id="org-001",
|
||||
name="Contoso",
|
||||
on_premises_sync_enabled=False,
|
||||
),
|
||||
Organization(
|
||||
id="org-002",
|
||||
name="Contoso Two",
|
||||
on_premises_sync_enabled=False,
|
||||
),
|
||||
]
|
||||
|
||||
check = entra_pim_stale_sign_in_alert()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "not available" in result[0].status_extended
|
||||
assert "P2" in result[0].status_extended
|
||||
assert result[0].resource_id == "org-001"
|
||||
assert result[0].resource_name == "Contoso"
|
||||
assert result[0].location == "global"
|
||||
|
||||
def test_inactive_alert_with_lingering_affected_items(self):
|
||||
"""PASS: alert reports affected items but is not active."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
|
||||
entra_pim_stale_sign_in_alert,
|
||||
)
|
||||
|
||||
entra_client.pim_alerts = {
|
||||
"DirectoryRole_StaleSignInAlert": PimAlert(
|
||||
id="alert-resolved",
|
||||
alert_definition_id="DirectoryRole_StaleSignInAlert",
|
||||
scope_id="/",
|
||||
scope_type="DirectoryRole",
|
||||
is_active=False,
|
||||
number_of_affected_items=3,
|
||||
incident_count=3,
|
||||
affected_items=[
|
||||
PimAlertIncident(
|
||||
assignee_display_name="Resolved User",
|
||||
assignee_id="user-resolved",
|
||||
role_display_name="Global Administrator",
|
||||
last_sign_in_date_time="2024-01-01T00:00:00Z",
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_pim_stale_sign_in_alert()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "no stale accounts" in result[0].status_extended
|
||||
assert result[0].resource_id == "alert-resolved"
|
||||
|
||||
def test_empty_pim_alerts_no_organizations(self):
|
||||
"""No findings when PIM alerts empty and no organizations."""
|
||||
entra_client = mock.MagicMock()
|
||||
|
||||
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_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
|
||||
entra_pim_stale_sign_in_alert,
|
||||
)
|
||||
|
||||
entra_client.pim_alerts = {}
|
||||
entra_client.organizations = []
|
||||
|
||||
check = entra_pim_stale_sign_in_alert()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
-131
@@ -11,7 +11,6 @@ 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(
|
||||
@@ -54,7 +53,6 @@ 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(
|
||||
@@ -97,7 +95,6 @@ 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(
|
||||
@@ -156,7 +153,6 @@ 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(
|
||||
@@ -195,7 +191,6 @@ 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(
|
||||
@@ -253,7 +248,6 @@ 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(
|
||||
@@ -292,7 +286,6 @@ 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(
|
||||
@@ -331,7 +324,6 @@ 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(
|
||||
@@ -391,7 +383,6 @@ 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(
|
||||
@@ -429,125 +420,3 @@ 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,26 +521,10 @@ class Test_Entra_Service:
|
||||
|
||||
assert len(users) == 6
|
||||
assert users_builder.get.await_count == 1
|
||||
# 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",
|
||||
}
|
||||
assert users_builder.get.await_args.kwargs == {}
|
||||
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
|
||||
@@ -548,81 +532,6 @@ 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)
|
||||
|
||||
@@ -664,11 +573,10 @@ class Test_Entra_Service:
|
||||
)
|
||||
)
|
||||
|
||||
registration_details, error_message = asyncio.run(
|
||||
registration_details = asyncio.run(
|
||||
entra_service._get_user_registration_details()
|
||||
)
|
||||
|
||||
assert error_message is None
|
||||
assert registration_details == {
|
||||
"user-1": {
|
||||
"is_mfa_capable": True,
|
||||
@@ -685,34 +593,3 @@ 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
|
||||
|
||||
+1
-18
@@ -2,28 +2,11 @@
|
||||
|
||||
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
|
||||
|
||||
- ASD Essential Eight compliance framework support [(#11071)](https://github.com/prowler-cloud/prowler/pull/11071)
|
||||
- ASD Essential Eight compliance framework support: AWS scans now surface the Essential Eight overview card, accordion view (Sections normalised to `N. <name>`, controls grouped per ML1 clause) and a dedicated requirement detail panel with maturity level, assessment, cloud applicability, mitigated threats and References [(#11071)](https://github.com/prowler-cloud/prowler/pull/11071)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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 prose-code:before:content-none prose-code:after:content-none max-w-none break-words whitespace-normal">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none break-words whitespace-normal">
|
||||
<ReactMarkdown>{children}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
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: () => routerMocks,
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/roles/roles", () => ({
|
||||
@@ -78,7 +73,6 @@ vi.mock("@/components/ui", () => ({
|
||||
|
||||
describe("AddRoleForm", () => {
|
||||
afterEach(() => {
|
||||
routerMocks.push.mockClear();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -105,16 +99,4 @@ 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,11 +252,7 @@ export const AddRoleForm = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FormButtons
|
||||
submitText="Add Role"
|
||||
isDisabled={isLoading}
|
||||
onCancel={() => router.push("/roles")}
|
||||
/>
|
||||
<FormButtons submitText="Add Role" isDisabled={isLoading} />
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -271,11 +271,7 @@ export const EditRoleForm = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<FormButtons
|
||||
submitText="Update Role"
|
||||
isDisabled={isLoading}
|
||||
onCancel={() => router.push("/roles")}
|
||||
/>
|
||||
<FormButtons submitText="Update Role" isDisabled={isLoading} />
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -239,46 +239,6 @@ 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,7 +10,6 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type WheelEvent,
|
||||
} from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
@@ -50,10 +49,6 @@ type MultiSelectContextType = {
|
||||
};
|
||||
const MultiSelectContext = createContext<MultiSelectContextType | null>(null);
|
||||
|
||||
const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
export function MultiSelect({
|
||||
children,
|
||||
values,
|
||||
@@ -340,16 +335,12 @@ 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 overflow-hidden 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 rounded-lg border p-0",
|
||||
widthClasses,
|
||||
)}
|
||||
>
|
||||
<Command {...props} className="max-h-[inherit] rounded-lg">
|
||||
<Command {...props} className="rounded-lg">
|
||||
{canSearch ? (
|
||||
<CommandInput
|
||||
placeholder={
|
||||
@@ -363,12 +354,7 @@ export function MultiSelectContent({
|
||||
)}
|
||||
<CommandList
|
||||
ref={listRef}
|
||||
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"
|
||||
className="minimal-scrollbar max-h-[300px] overflow-x-hidden overflow-y-auto p-3"
|
||||
>
|
||||
{canSearch && (
|
||||
<CommandEmpty className="text-bg-button-secondary py-6 text-center text-sm">
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
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,14 +2,10 @@
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { ComponentProps, type WheelEvent } from "react";
|
||||
import { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const stopWheelPropagation = (event: WheelEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
function Select({
|
||||
allowDeselect = false,
|
||||
...props
|
||||
@@ -87,7 +83,6 @@ function SelectContent({
|
||||
position = "popper",
|
||||
align = "start",
|
||||
width = "default",
|
||||
style,
|
||||
...props
|
||||
}: ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
width?: "default" | "wide";
|
||||
@@ -102,32 +97,22 @@ 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-hidden 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-x-hidden overflow-y-auto 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(
|
||||
"minimal-scrollbar flex flex-col gap-1 overflow-x-hidden overflow-y-auto overscroll-contain p-3",
|
||||
"flex flex-col gap-1 p-3",
|
||||
position === "popper" &&
|
||||
"w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user