Compare commits

..

13 Commits

Author SHA1 Message Date
Prowler Bot
1b0ce40f76 fix: add pagination for m365 and azure users retrieval (#8868)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2025-10-08 09:16:37 +02:00
Prowler Bot
c66ef6b4db fix: remove maxTokens for gpt-5 (#8857)
Co-authored-by: Chandrapal Badshah <Chan9390@users.noreply.github.com>
2025-10-07 10:26:54 +02:00
Prowler Bot
bf7e363415 fix(compliance): generate file extension correctly (#8845)
Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-10-06 15:28:27 +02:00
Prowler Bot
5519462e82 fix: handle eks cluster version and listener certificate arn not in acm (#8806)
Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-10-01 18:02:36 -04:00
Prowler Bot
572e6ffa17 chore(release): Bump version to v5.12.4 (#8798)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2025-10-01 09:21:55 -04:00
Prowler Bot
d54993ec58 fix(workflows): load latest SDK only for master (#8797)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-10-01 13:46:24 +05:45
César Arroba
9290656069 chore(api): update api version 2025-09-30 16:13:39 +02:00
César Arroba
721220d84f chore(ui): fix changelog version 2025-09-30 16:12:53 +02:00
Prowler Bot
8b6d0331f6 fix(user): PermissionError, 500, when deleting user (#8786)
Co-authored-by: Josema Camacho <josema@prowler.com>
2025-09-30 10:56:17 +02:00
Prowler Bot
00e5422654 fix(lighthouse): make Enter submit text (#8747)
Co-authored-by: Chandrapal Badshah <Chan9390@users.noreply.github.com>
Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2025-09-26 09:01:38 +02:00
Prowler Bot
8ff3972635 fix(lighthouse): allow scrolling during AI response streaming (#8743)
Co-authored-by: Chandrapal Badshah <Chan9390@users.noreply.github.com>
Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2025-09-26 08:47:45 +02:00
Prowler Bot
4f298ac46d fix(scans): update link disable condition for findings table (#8765)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2025-09-25 13:04:57 +02:00
Prowler Bot
8fc48e81f2 chore(release): Bump version to v5.12.3 (#8759)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2025-09-24 16:31:53 +02:00
25 changed files with 793 additions and 110 deletions

View File

@@ -87,9 +87,9 @@ jobs:
.github/workflows/api-pull-request.yml
files_ignore: ${{ env.IGNORE_FILES }}
- name: Replace @master with current branch in pyproject.toml
- name: Replace @master with current branch in pyproject.toml - Only for pull requests to `master`
working-directory: ./api
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true'
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' && github.event_name == 'pull_request' && github.base_ref == 'master'
run: |
BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
echo "Using branch: $BRANCH_NAME"
@@ -110,7 +110,7 @@ jobs:
- name: Update SDK's poetry.lock resolved_reference to latest commit - Only for push events to `master`
working-directory: ./api
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' && github.event_name == 'push'
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/master'
run: |
# Get the latest commit hash from the prowler-cloud/prowler repository
LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha')

View File

@@ -2,6 +2,13 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.13.2] (Prowler 5.12.3)
### Fixed
- 500 error when deleting user [(#8731)](https://github.com/prowler-cloud/prowler/pull/8731)
---
## [1.13.1] (Prowler 5.12.2)
### Changed

View File

@@ -39,7 +39,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.13.1"
version = "1.13.2"
[project.scripts]
celery = "src.backend.config.settings.celery"

View File

@@ -13,6 +13,7 @@ from uuid import uuid4
import jwt
import pytest
from allauth.socialaccount.models import SocialAccount, SocialApp
from allauth.account.models import EmailAddress
from botocore.exceptions import ClientError, NoCredentialsError
from conftest import (
API_JSON_CONTENT_TYPE,
@@ -324,6 +325,78 @@ class TestUserViewSet:
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert User.objects.filter(id=another_user.id).exists()
def test_users_destroy_cascades_allauth_and_memberships(
self, authenticated_client, create_test_user
):
# Create related admin-side objects (email + SocialAccount)
EmailAddress.objects.create(
user=create_test_user,
email=create_test_user.email,
primary=True,
verified=True,
)
SocialAccount.objects.create(
user=create_test_user, provider="fake-provider", uid="uid-fake-provider"
)
# Sanity check pre-conditions
assert EmailAddress.objects.filter(user=create_test_user).exists()
assert SocialAccount.objects.filter(user=create_test_user).exists()
assert Membership.objects.filter(user=create_test_user).exists()
assert UserRoleRelationship.objects.filter(user=create_test_user).exists()
# Delete current user
response = authenticated_client.delete(
reverse("user-detail", kwargs={"pk": str(create_test_user.id)})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Assert user and related objects are gone
assert not User.objects.filter(id=create_test_user.id).exists()
assert not EmailAddress.objects.filter(user_id=create_test_user.id).exists()
assert not SocialAccount.objects.filter(user_id=create_test_user.id).exists()
assert not Membership.objects.filter(user_id=create_test_user.id).exists()
assert not UserRoleRelationship.objects.filter(
user_id=create_test_user.id
).exists()
def test_users_destroy_with_saml_configuration_and_memberships(
self, authenticated_client, create_test_user, saml_setup
):
# Ensure SAML configuration exists for tenant (from saml_setup fixture)
domain = saml_setup["domain"]
config = SAMLConfiguration.objects.get(email_domain=domain)
# Attach a SAML SocialAccount to the user
SocialAccount.objects.create(
user=create_test_user, provider="saml", uid="uid-saml"
)
# Sanity check pre-conditions
assert SocialAccount.objects.filter(
user=create_test_user, provider="saml"
).exists()
assert Membership.objects.filter(user=create_test_user).exists()
assert UserRoleRelationship.objects.filter(user=create_test_user).exists()
# Delete current user
response = authenticated_client.delete(
reverse("user-detail", kwargs={"pk": str(create_test_user.id)})
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Assert user-related rows are removed
assert not User.objects.filter(id=create_test_user.id).exists()
assert not SocialAccount.objects.filter(user_id=create_test_user.id).exists()
assert not Membership.objects.filter(user_id=create_test_user.id).exists()
assert not UserRoleRelationship.objects.filter(
user_id=create_test_user.id
).exists()
# Tenant-level SAML configuration should remain intact
assert SAMLConfiguration.objects.filter(id=config.id).exists()
assert SocialApp.objects.filter(provider="saml", client_id=domain).exists()
@pytest.mark.parametrize(
"attribute_key, attribute_value, error_field",
[

View File

@@ -300,7 +300,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.13.1"
spectacular_settings.VERSION = "1.13.2"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -803,7 +803,9 @@ class UserViewSet(BaseUserViewset):
if kwargs["pk"] != str(self.request.user.id):
raise ValidationError("Only the current user can be deleted.")
return super().destroy(request, *args, **kwargs)
user = self.get_object()
user.delete(using=MainRouter.admin_db)
return Response(status=status.HTTP_204_NO_CONTENT)
@extend_schema(
parameters=[

View File

@@ -2,6 +2,15 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [v5.12.4] (Prowler UNRELEASED)
### Fixed
- Fix KeyError in `elb_ssl_listeners_use_acm_certificate` check and handle None cluster version in `eks_cluster_uses_a_supported_version` check [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791)
- Fix file extension parsing for compliance reports [(#8791)](https://github.com/prowler-cloud/prowler/pull/8791)
- Added user pagination to Entra and Admincenter services [(#8858)](https://github.com/prowler-cloud/prowler/pull/8858)
---
## [v5.12.1] (Prowler v5.12.1)
### Fixed
@@ -9,6 +18,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `firehose_stream_encrypted_at_rest` check false positives and new api call in kafka service [(#8599)](https://github.com/prowler-cloud/prowler/pull/8599)
- Replace defender rules policies key to use old name [(#8702)](https://github.com/prowler-cloud/prowler/pull/8702)
---
## [v5.12.0] (Prowler v5.12.0)
### Added

View File

@@ -12,7 +12,7 @@ from prowler.lib.logger import logger
timestamp = datetime.today()
timestamp_utc = datetime.now(timezone.utc).replace(tzinfo=timezone.utc)
prowler_version = "5.12.2"
prowler_version = "5.12.4"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://prowler.com/wp-content/uploads/logo-html.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"

View File

@@ -42,7 +42,10 @@ class ComplianceOutput(Output):
self._from_cli = from_cli
if not file_extension and file_path:
self._file_extension = "".join(Path(file_path).suffixes)
# Compliance reports are always CSV, so just use the last suffix
# e.g., "cis_5.0_aws.csv" should have extension ".csv", not ".0_aws.csv"
path_obj = Path(file_path)
self._file_extension = path_obj.suffix if path_obj.suffix else ""
if file_extension:
self._file_extension = file_extension
self.file_path = f"{file_path}{self.file_extension}"

View File

@@ -16,6 +16,10 @@ class eks_cluster_uses_a_supported_version(Check):
for cluster in eks_client.clusters:
report = Check_Report_AWS(metadata=self.metadata(), resource=cluster)
# Handle case where cluster.version might be None (edge case during cluster creation/deletion)
if not cluster.version:
continue
cluster_version_major, cluster_version_minor = map(
int, cluster.version.split(".")
)

View File

@@ -15,8 +15,14 @@ class elb_ssl_listeners_use_acm_certificate(Check):
if (
listener.certificate_arn
and listener.protocol in secure_protocols
and acm_client.certificates[listener.certificate_arn].type
!= "AMAZON_ISSUED"
and (
listener.certificate_arn not in acm_client.certificates
or (
acm_client.certificates.get(listener.certificate_arn)
and acm_client.certificates[listener.certificate_arn].type
!= "AMAZON_ISSUED"
)
)
):
report.status = "FAIL"
report.status_extended = f"ELB {lb.name} has HTTPS/SSL listeners that are using certificates not managed by ACM."

View File

@@ -1,4 +1,5 @@
from asyncio import gather, get_event_loop
import asyncio
from asyncio import gather
from typing import List, Optional
from uuid import UUID
@@ -15,7 +16,23 @@ class Entra(AzureService):
def __init__(self, provider: AzureProvider):
super().__init__(GraphServiceClient, provider)
loop = get_event_loop()
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_running():
raise RuntimeError(
"Cannot initialize Entra service while event loop is running"
)
# Get users first alone because it is a dependency for other attributes
self.users = loop.run_until_complete(self._get_users())
@@ -38,36 +55,48 @@ class Entra(AzureService):
self.directory_roles = attributes[4]
self.conditional_access_policy = attributes[5]
if created_loop:
asyncio.set_event_loop(None)
loop.close()
async def _get_users(self):
logger.info("Entra - Getting users...")
users = {}
try:
for tenant, client in self.clients.items():
users_list = await client.users.get()
users.update({tenant: {}})
users_response = await client.users.get()
try:
for user in users_list.value:
users[tenant].update(
{
user.id: User(
id=user.id,
name=user.display_name,
authentication_methods=[
AuthMethod(
id=auth_method.id,
type=getattr(
auth_method, "odata_type", None
),
)
for auth_method in (
await client.users.by_user_id(
user.id
).authentication.methods.get()
).value
],
)
}
)
while users_response:
for user in getattr(users_response, "value", []) or []:
users[tenant].update(
{
user.id: User(
id=user.id,
name=user.display_name,
authentication_methods=[
AuthMethod(
id=auth_method.id,
type=getattr(
auth_method, "odata_type", None
),
)
for auth_method in (
await client.users.by_user_id(
user.id
).authentication.methods.get()
).value
],
)
}
)
next_link = getattr(users_response, "odata_next_link", None)
if not next_link:
break
users_response = await client.users.with_url(next_link).get()
except Exception as error:
if (
error.__class__.__name__ == "ODataError"

View File

@@ -1,4 +1,4 @@
from asyncio import gather, get_event_loop
import asyncio
from typing import List, Optional
from pydantic.v1 import BaseModel
@@ -20,13 +20,29 @@ class AdminCenter(M365Service):
self.sharing_policy = self._get_sharing_policy()
self.powershell.close()
loop = get_event_loop()
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_running():
raise RuntimeError(
"Cannot initialize AdminCenter service while event loop is running"
)
# Get users first alone because it is a dependency for other attributes
self.users = loop.run_until_complete(self._get_users())
attributes = loop.run_until_complete(
gather(
asyncio.gather(
self._get_directory_roles(),
self._get_groups(),
self._get_domains(),
@@ -37,6 +53,10 @@ class AdminCenter(M365Service):
self.groups = attributes[1]
self.domains = attributes[2]
if created_loop:
asyncio.set_event_loop(None)
loop.close()
def _get_organization_config(self):
logger.info("Microsoft365 - Getting Exchange Organization configuration...")
organization_config = None
@@ -77,27 +97,36 @@ class AdminCenter(M365Service):
logger.info("M365 - Getting users...")
users = {}
try:
users_list = await self.client.users.get()
users.update({})
for user in users_list.value:
license_details = await self.client.users.by_user_id(
user.id
).license_details.get()
users.update(
{
user.id: User(
id=user.id,
name=getattr(user, "display_name", ""),
license=(
getattr(
license_details.value[0], "sku_part_number", None
)
if license_details.value
else None
),
)
}
)
users_response = await self.client.users.get()
while users_response:
for user in getattr(users_response, "value", []) or []:
license_details = await self.client.users.by_user_id(
user.id
).license_details.get()
users.update(
{
user.id: User(
id=user.id,
name=getattr(user, "display_name", ""),
license=(
getattr(
license_details.value[0],
"sku_part_number",
None,
)
if license_details.value
else None
),
)
}
)
next_link = getattr(users_response, "odata_next_link", None)
if not next_link:
break
users_response = await self.client.users.with_url(next_link).get()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"

View File

@@ -1,5 +1,5 @@
import asyncio
from asyncio import gather, get_event_loop
from asyncio import gather
from enum import Enum
from typing import List, Optional
from uuid import UUID
@@ -20,7 +20,24 @@ class Entra(M365Service):
self.user_accounts_status = self.powershell.get_user_account_status()
self.powershell.close()
loop = get_event_loop()
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_running():
raise RuntimeError(
"Cannot initialize Entra service while event loop is running"
)
self.tenant_domain = provider.identity.tenant_domain
attributes = loop.run_until_complete(
gather(
@@ -41,6 +58,10 @@ class Entra(M365Service):
self.users = attributes[5]
self.user_accounts_status = {}
if created_loop:
asyncio.set_event_loop(None)
loop.close()
async def _get_authorization_policy(self):
logger.info("Entra - Getting authorization policy...")
authorization_policy = None
@@ -364,7 +385,7 @@ class Entra(M365Service):
logger.info("Entra - Getting users...")
users = {}
try:
users_list = await self.client.users.get()
users_response = await self.client.users.get()
directory_roles = await self.client.directory_roles.get()
async def fetch_role_members(directory_role):
@@ -396,23 +417,29 @@ class Entra(M365Service):
)
registration_details = {}
for user in users_list.value:
users[user.id] = User(
id=user.id,
name=user.display_name,
on_premises_sync_enabled=(
True if (user.on_premises_sync_enabled) else False
),
directory_roles_ids=user_roles_map.get(user.id, []),
is_mfa_capable=(
registration_details.get(user.id, {}).is_mfa_capable
if registration_details.get(user.id, None) is not None
else False
),
account_enabled=not self.user_accounts_status.get(user.id, {}).get(
"AccountDisabled", False
),
)
while users_response:
for user in getattr(users_response, "value", []) or []:
users[user.id] = User(
id=user.id,
name=user.display_name,
on_premises_sync_enabled=(
True if (user.on_premises_sync_enabled) else False
),
directory_roles_ids=user_roles_map.get(user.id, []),
is_mfa_capable=(
registration_details.get(user.id, {}).is_mfa_capable
if registration_details.get(user.id, None) is not None
else False
),
account_enabled=not self.user_accounts_status.get(
user.id, {}
).get("AccountDisabled", False),
)
next_link = getattr(users_response, "odata_next_link", None)
if not next_link:
break
users_response = await self.client.users.with_url(next_link).get()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"

View File

@@ -1,5 +1,5 @@
import asyncio
import uuid
from asyncio import gather, get_event_loop
from typing import List, Optional
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
@@ -16,15 +16,36 @@ class SharePoint(M365Service):
if self.powershell:
self.powershell.close()
loop = get_event_loop()
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_running():
raise RuntimeError(
"Cannot initialize SharePoint service while event loop is running"
)
self.tenant_domain = provider.identity.tenant_domain
attributes = loop.run_until_complete(
gather(
asyncio.gather(
self._get_settings(),
)
)
self.settings = attributes[0]
if created_loop:
asyncio.set_event_loop(None)
loop.close()
async def _get_settings(self):
logger.info("M365 - Getting SharePoint global settings...")
settings = None

View File

@@ -74,7 +74,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">3.9.1,<3.13"
version = "5.12.2"
version = "5.12.4"
[project.scripts]
prowler = "prowler.__main__:prowler"

View File

@@ -382,3 +382,54 @@ class TestCompliance:
assert get_check_compliance(finding, "github", bulk_checks_metadata) == {
"CIS-1.0": ["1.1.11"],
}
class TestComplianceOutput:
"""Test ComplianceOutput file extension parsing fix."""
def test_compliance_output_file_extension_with_dots(self):
"""Test that ComplianceOutput correctly parses file extensions when framework names contain dots."""
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
compliance = Compliance(
Framework="CIS",
Version="5.0",
Provider="AWS",
Name="CIS Amazon Web Services Foundations Benchmark v5.0",
Description="Test compliance framework",
Requirements=[],
)
# Test with problematic file path that contains dots in framework name
# This simulates the real scenario from Prowler App S3 integration
problematic_file_path = "output/compliance/prowler-output-123456789012-20250101120000_cis_5.0_aws.csv"
# Create GenericCompliance object with file_path (no explicit file_extension)
compliance_output = GenericCompliance(
findings=[], compliance=compliance, file_path=problematic_file_path
)
assert compliance_output.file_extension == ".csv"
assert compliance_output.file_extension != ".0_aws.csv"
def test_compliance_output_file_extension_explicit(self):
"""Test that ComplianceOutput uses explicit file_extension when provided."""
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
compliance = Compliance(
Framework="CIS",
Version="5.0",
Provider="AWS",
Name="CIS Amazon Web Services Foundations Benchmark v5.0",
Description="Test compliance framework",
Requirements=[],
)
compliance_output = GenericCompliance(
findings=[],
compliance=compliance,
file_path="output/compliance/test",
file_extension=".csv",
)
assert compliance_output.file_extension == ".csv"

View File

@@ -13,6 +13,7 @@ class Test_eks_cluster_ensure_version_is_supported:
def test_no_clusters(self):
eks_client = mock.MagicMock
eks_client.clusters = []
eks_client.audit_config = {"eks_cluster_oldest_version_supported": "1.28"}
with mock.patch(
"prowler.providers.aws.services.eks.eks_service.EKS",
eks_client,
@@ -53,7 +54,7 @@ class Test_eks_cluster_ensure_version_is_supported:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"EKS cluster {cluster_name} is in version 1.22. It should be one of the next supported versions: 1.28 or higher"
== f"EKS cluster {cluster_name} is using version 1.22. It should be one of the supported versions: 1.28 or higher."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
@@ -88,7 +89,7 @@ class Test_eks_cluster_ensure_version_is_supported:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"EKS cluster {cluster_name} is in version 0.22. It should be one of the next supported versions: 1.28 or higher"
== f"EKS cluster {cluster_name} is using version 0.22. It should be one of the supported versions: 1.28 or higher."
)
assert result[0].resource_id == cluster_name
assert result[0].resource_arn == cluster_arn
@@ -199,3 +200,31 @@ class Test_eks_cluster_ensure_version_is_supported:
assert result[0].resource_arn == cluster_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION_EU_WEST_1
def test_eks_cluster_with_none_version(self):
"""Test EKS cluster with version=None - should return FAIL gracefully"""
eks_client = mock.MagicMock
eks_client.audit_config = {"eks_cluster_oldest_version_supported": "1.28"}
eks_client.clusters = []
eks_client.clusters.append(
EKSCluster(
name=cluster_name,
version=None, # This should trigger the AttributeError in current implementation
arn=cluster_arn,
region=AWS_REGION_EU_WEST_1,
logging=None,
)
)
with mock.patch(
"prowler.providers.aws.services.eks.eks_service.EKS",
eks_client,
):
from prowler.providers.aws.services.eks.eks_cluster_uses_a_supported_version.eks_cluster_uses_a_supported_version import (
eks_cluster_uses_a_supported_version,
)
check = eks_cluster_uses_a_supported_version()
result = check.execute()
assert len(result) == 0

View File

@@ -364,3 +364,144 @@ class Test_elb_ssl_listeners_use_acm_certificate:
assert result[0].resource_arn == elb_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION
@mock_aws
def test_elb_with_HTTPS_listener_IAM_certificate(self):
"""Test ELB with HTTPS listener using IAM certificate (not ACM) - should return FAIL"""
elb = client("elb", region_name=AWS_REGION)
ec2 = resource("ec2", region_name=AWS_REGION)
# Create IAM certificate (not ACM)
iam_certificate_arn = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:server-certificate/test-certificate"
)
security_group = ec2.create_security_group(
GroupName="sg01", Description="Test security group sg01"
)
elb.create_load_balancer(
LoadBalancerName="my-lb",
Listeners=[
{
"Protocol": "https",
"LoadBalancerPort": 80,
"InstancePort": 8080,
"SSLCertificateId": iam_certificate_arn,
},
],
AvailabilityZones=[AWS_REGION_EU_WEST_1_AZA],
Scheme="internal",
SecurityGroups=[security_group.id],
)
from prowler.providers.aws.services.acm.acm_service import ACM
from prowler.providers.aws.services.elb.elb_service import ELB
aws_mocked_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_mocked_provider,
),
mock.patch(
"prowler.providers.aws.services.elb.elb_ssl_listeners_use_acm_certificate.elb_ssl_listeners_use_acm_certificate.elb_client",
new=ELB(aws_mocked_provider),
),
mock.patch(
"prowler.providers.aws.services.elb.elb_ssl_listeners_use_acm_certificate.elb_ssl_listeners_use_acm_certificate.acm_client",
new=ACM(aws_mocked_provider),
),
):
from prowler.providers.aws.services.elb.elb_ssl_listeners_use_acm_certificate.elb_ssl_listeners_use_acm_certificate import (
elb_ssl_listeners_use_acm_certificate,
)
check = elb_ssl_listeners_use_acm_certificate()
# This should now work correctly and return FAIL for IAM certificate
# (unless there's still a KeyError in the current implementation)
result = check.execute()
# Expected behavior: FAIL because IAM certificate is not managed by ACM
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "ELB my-lb has HTTPS/SSL listeners that are using certificates not managed by ACM."
)
assert result[0].resource_id == "my-lb"
assert result[0].resource_arn == elb_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION
@mock_aws
def test_elb_with_HTTPS_listener_certificate_not_in_acm(self):
"""Test ELB with HTTPS listener using certificate that triggers not in acm_client.certificates condition"""
elb = client("elb", region_name=AWS_REGION)
ec2 = resource("ec2", region_name=AWS_REGION)
# Create a certificate ARN that will NOT be in ACM (simulating IAM certificate or any non-ACM certificate)
# This will trigger the first condition: listener.certificate_arn not in acm_client.certificates
non_acm_certificate_arn = (
f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:server-certificate/non-acm-cert"
)
security_group = ec2.create_security_group(
GroupName="sg01", Description="Test security group sg01"
)
elb.create_load_balancer(
LoadBalancerName="my-lb",
Listeners=[
{
"Protocol": "https",
"LoadBalancerPort": 80,
"InstancePort": 8080,
"SSLCertificateId": non_acm_certificate_arn,
},
],
AvailabilityZones=[AWS_REGION_EU_WEST_1_AZA],
Scheme="internal",
SecurityGroups=[security_group.id],
)
from prowler.providers.aws.services.acm.acm_service import ACM
from prowler.providers.aws.services.elb.elb_service import ELB
aws_mocked_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_mocked_provider,
),
mock.patch(
"prowler.providers.aws.services.elb.elb_ssl_listeners_use_acm_certificate.elb_ssl_listeners_use_acm_certificate.elb_client",
new=ELB(aws_mocked_provider),
),
mock.patch(
"prowler.providers.aws.services.elb.elb_ssl_listeners_use_acm_certificate.elb_ssl_listeners_use_acm_certificate.acm_client",
new=ACM(aws_mocked_provider),
),
):
from prowler.providers.aws.services.elb.elb_ssl_listeners_use_acm_certificate.elb_ssl_listeners_use_acm_certificate import (
elb_ssl_listeners_use_acm_certificate,
)
check = elb_ssl_listeners_use_acm_certificate()
result = check.execute()
# This should trigger the first condition: listener.certificate_arn not in acm_client.certificates
# and return FAIL without ever reaching the second part of the OR condition
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "ELB my-lb has HTTPS/SSL listeners that are using certificates not managed by ACM."
)
assert result[0].resource_id == "my-lb"
assert result[0].resource_arn == elb_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION

View File

@@ -1,4 +1,6 @@
from unittest.mock import patch
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from prowler.providers.azure.models import AzureIdentityInfo
@@ -223,3 +225,64 @@ class Test_Entra_Service:
]
== []
)
def test_azure_entra__get_users_handles_pagination():
entra_service = Entra.__new__(Entra)
users_page_one = [
SimpleNamespace(id="user-1", display_name="User 1"),
SimpleNamespace(id="user-2", display_name="User 2"),
]
users_page_two = [
SimpleNamespace(id="user-3", display_name="User 3"),
]
users_response_page_one = SimpleNamespace(
value=users_page_one,
odata_next_link="next-link",
)
users_response_page_two = SimpleNamespace(
value=users_page_two, odata_next_link=None
)
users_with_url_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_two)
)
with_url_mock = MagicMock(return_value=users_with_url_builder)
def by_user_id_side_effect(user_id):
auth_methods_response = SimpleNamespace(
value=[
SimpleNamespace(
id=f"{user_id}-method",
odata_type="#microsoft.graph.passwordAuthenticationMethod",
)
]
)
return SimpleNamespace(
authentication=SimpleNamespace(
methods=SimpleNamespace(
get=AsyncMock(return_value=auth_methods_response)
)
)
)
users_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_one),
with_url=with_url_mock,
by_user_id=MagicMock(side_effect=by_user_id_side_effect),
)
entra_service.clients = {"tenant-1": SimpleNamespace(users=users_builder)}
users = asyncio.run(entra_service._get_users())
assert len(users["tenant-1"]) == 3
assert users_builder.get.await_count == 1
with_url_mock.assert_called_once_with("next-link")
assert users["tenant-1"]["user-1"].authentication_methods[0].id == "user-1-method"
assert (
users["tenant-1"]["user-3"].authentication_methods[0].type
== "#microsoft.graph.passwordAuthenticationMethod"
)

View File

@@ -1,5 +1,7 @@
import asyncio
from types import SimpleNamespace
from unittest import mock
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
from prowler.providers.m365.models import M365IdentityInfo
from prowler.providers.m365.services.admincenter.admincenter_service import (
@@ -161,3 +163,54 @@ class Test_AdminCenter_Service:
assert admincenter_client.sharing_policy.name == "Test"
assert admincenter_client.sharing_policy.enabled is False
admincenter_client.powershell.close()
def test_admincenter__get_users_handles_pagination():
admincenter_service = AdminCenter.__new__(AdminCenter)
users_page_one = [
SimpleNamespace(id="user-1", display_name="User 1"),
SimpleNamespace(id="user-2", display_name="User 2"),
]
users_page_two = [
SimpleNamespace(id="user-3", display_name="User 3"),
]
users_response_page_one = SimpleNamespace(
value=users_page_one,
odata_next_link="next-link",
)
users_response_page_two = SimpleNamespace(
value=users_page_two, odata_next_link=None
)
users_with_url_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_two)
)
with_url_mock = MagicMock(return_value=users_with_url_builder)
def by_user_id_side_effect(user_id):
license_details_response = SimpleNamespace(
value=[SimpleNamespace(sku_part_number=f"SKU-{user_id}")]
)
return SimpleNamespace(
license_details=SimpleNamespace(
get=AsyncMock(return_value=license_details_response)
)
)
users_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_one),
with_url=with_url_mock,
by_user_id=MagicMock(side_effect=by_user_id_side_effect),
)
admincenter_service.client = SimpleNamespace(users=users_builder)
users = asyncio.run(admincenter_service._get_users())
assert len(users) == 3
assert users_builder.get.await_count == 1
with_url_mock.assert_called_once_with("next-link")
assert users["user-1"].license == "SKU-user-1"
assert users["user-3"].license == "SKU-user-3"

View File

@@ -1,4 +1,6 @@
from unittest.mock import patch
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from prowler.providers.m365.models import M365IdentityInfo
from prowler.providers.m365.services.entra.entra_service import (
@@ -155,17 +157,21 @@ async def mock_entra_get_organization(_):
class Test_Entra_Service:
def test_get_client(self):
admincenter_client = Entra(
set_mocked_m365_provider(identity=M365IdentityInfo(tenant_domain=DOMAIN))
)
assert admincenter_client.client.__class__.__name__ == "GraphServiceClient"
with patch("prowler.providers.m365.lib.service.service.M365PowerShell"):
admincenter_client = Entra(
set_mocked_m365_provider(
identity=M365IdentityInfo(tenant_domain=DOMAIN)
)
)
assert admincenter_client.client.__class__.__name__ == "GraphServiceClient"
@patch(
"prowler.providers.m365.services.entra.entra_service.Entra._get_authorization_policy",
new=mock_entra_get_authorization_policy,
)
def test_get_authorization_policy(self):
entra_client = Entra(set_mocked_m365_provider())
with patch("prowler.providers.m365.lib.service.service.M365PowerShell"):
entra_client = Entra(set_mocked_m365_provider())
assert entra_client.authorization_policy.id == "id-1"
assert entra_client.authorization_policy.name == "Name 1"
assert entra_client.authorization_policy.description == "Description 1"
@@ -193,7 +199,8 @@ class Test_Entra_Service:
new=mock_entra_get_conditional_access_policies,
)
def test_get_conditional_access_policies(self):
entra_client = Entra(set_mocked_m365_provider())
with patch("prowler.providers.m365.lib.service.service.M365PowerShell"):
entra_client = Entra(set_mocked_m365_provider())
assert entra_client.conditional_access_policies == {
"id-1": ConditionalAccessPolicy(
id="id-1",
@@ -242,7 +249,8 @@ class Test_Entra_Service:
new=mock_entra_get_groups,
)
def test_get_groups(self):
entra_client = Entra(set_mocked_m365_provider())
with patch("prowler.providers.m365.lib.service.service.M365PowerShell"):
entra_client = Entra(set_mocked_m365_provider())
assert len(entra_client.groups) == 2
assert entra_client.groups[0]["id"] == "id-1"
assert entra_client.groups[0]["name"] == "group1"
@@ -258,7 +266,8 @@ class Test_Entra_Service:
new=mock_entra_get_admin_consent_policy,
)
def test_get_admin_consent_policy(self):
entra_client = Entra(set_mocked_m365_provider())
with patch("prowler.providers.m365.lib.service.service.M365PowerShell"):
entra_client = Entra(set_mocked_m365_provider())
assert entra_client.admin_consent_policy.admin_consent_enabled
assert entra_client.admin_consent_policy.notify_reviewers
assert entra_client.admin_consent_policy.email_reminders_to_reviewers is False
@@ -269,7 +278,8 @@ class Test_Entra_Service:
new=mock_entra_get_organization,
)
def test_get_organization(self):
entra_client = Entra(set_mocked_m365_provider())
with patch("prowler.providers.m365.lib.service.service.M365PowerShell"):
entra_client = Entra(set_mocked_m365_provider())
assert len(entra_client.organizations) == 1
assert entra_client.organizations[0].id == "org1"
assert entra_client.organizations[0].name == "Organization 1"
@@ -280,7 +290,8 @@ class Test_Entra_Service:
new=mock_entra_get_users,
)
def test_get_users(self):
entra_client = Entra(set_mocked_m365_provider())
with patch("prowler.providers.m365.lib.service.service.M365PowerShell"):
entra_client = Entra(set_mocked_m365_provider())
assert len(entra_client.users) == 3
assert entra_client.users["user-1"].id == "user-1"
assert entra_client.users["user-1"].name == "User 1"
@@ -303,3 +314,119 @@ class Test_Entra_Service:
]
assert entra_client.users["user-3"].on_premises_sync_enabled
assert not entra_client.users["user-3"].is_mfa_capable
def test__get_users_paginates_through_next_links(self):
entra_service = Entra.__new__(Entra)
entra_service.user_accounts_status = {"user-6": {"AccountDisabled": True}}
users_page_one = [
SimpleNamespace(
id="user-1",
display_name="User 1",
on_premises_sync_enabled=True,
),
SimpleNamespace(
id="user-2",
display_name="User 2",
on_premises_sync_enabled=False,
),
SimpleNamespace(
id="user-3",
display_name="User 3",
on_premises_sync_enabled=None,
),
SimpleNamespace(
id="user-4",
display_name="User 4",
on_premises_sync_enabled=True,
),
SimpleNamespace(
id="user-5",
display_name="User 5",
on_premises_sync_enabled=False,
),
]
users_page_two = [
SimpleNamespace(
id="user-6",
display_name="User 6",
on_premises_sync_enabled=True,
)
]
users_response_page_one = SimpleNamespace(
value=users_page_one,
odata_next_link="next-link",
)
users_response_page_two = SimpleNamespace(
value=users_page_two,
odata_next_link=None,
)
users_with_url_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_two)
)
with_url_mock = MagicMock(return_value=users_with_url_builder)
users_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_one),
with_url=with_url_mock,
)
role_members_response = SimpleNamespace(
value=[
SimpleNamespace(id="user-1"),
SimpleNamespace(id="user-6"),
]
)
members_builder = SimpleNamespace(
get=AsyncMock(return_value=role_members_response)
)
directory_roles_builder = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
value=[
SimpleNamespace(
id="role-1",
role_template_id="role-template-1",
)
]
)
),
by_directory_role_id=MagicMock(
return_value=SimpleNamespace(members=members_builder)
),
)
registration_details_response = SimpleNamespace(
value=[
SimpleNamespace(id="user-1", is_mfa_capable=True),
SimpleNamespace(id="user-6", is_mfa_capable=True),
]
)
registration_details_builder = SimpleNamespace(
get=AsyncMock(return_value=registration_details_response)
)
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) == 6
assert users_builder.get.await_count == 1
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"]
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

View File

@@ -3,6 +3,22 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.12.4] (Prowler v5.12.4)
### 🐞 Fixed
- Remove maxTokens model param for GPT-5 models [(#8843)](https://github.com/prowler-cloud/prowler/pull/8843)
## [1.12.3] (Prowler v5.12.3)
### 🐞 Fixed
- Disable "See Findings" button until scan completes [(#8762)](https://github.com/prowler-cloud/prowler/pull/8762)
- Scrolling during Lighthouse AI response streaming [(#8669)](https://github.com/prowler-cloud/prowler/pull/8669)
- Lighthouse textbox to send messages on Enter [(#8747)](https://github.com/prowler-cloud/prowler/pull/8747)
---
## [1.12.2] (Prowler v5.12.2)
### 🐞 Fixed

View File

@@ -134,7 +134,7 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
// Global keyboard shortcut handler
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (messageValue?.trim()) {
onFormSubmit();
@@ -146,16 +146,6 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [messageValue, onFormSubmit]);
useEffect(() => {
if (messagesContainerRef.current && latestUserMsgRef.current) {
const container = messagesContainerRef.current;
const userMsg = latestUserMsgRef.current;
const containerPadding = 16; // p-4 in Tailwind = 16px
container.scrollTop =
userMsg.offsetTop - container.offsetTop - containerPadding;
}
}, [messages]);
const suggestedActions: SuggestedAction[] = [
{
title: "Are there any exposed S3",

View File

@@ -108,7 +108,7 @@ export const ColumnGetScans: ColumnDef<ScanProps>[] = [
return (
<TableLink
href={`/findings?filter[scan__in]=${id}&filter[status__in]=FAIL`}
isDisabled={!["completed", "executing"].includes(scanState)}
isDisabled={scanState !== "completed"}
label="See Findings"
/>
);

View File

@@ -60,7 +60,8 @@ export const getModelParams = (config: any): ModelParams => {
if (modelId.startsWith("gpt-5")) {
params.temperature = undefined;
params.reasoningEffort = "minimal";
params.reasoningEffort = "minimal" as const;
params.maxTokens = undefined;
}
return params;