Compare commits

...

14 Commits

Author SHA1 Message Date
Prowler Bot aa729a9d2d chore(release): Bump versions to v5.28.2 (#11369)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-26 16:49:06 +02:00
Prowler Bot d086a624a0 fix(ui): honor page size select in compliance req findings (#11368)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-05-26 15:42:32 +02:00
Prowler Bot a7c2b6cbce fix(mcp_server): preserve authorization header in HTTP mode (#11367)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-05-26 15:31:30 +02:00
Prowler Bot 5da5848509 chore: SDK changelog v5.28.1 (#11364)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-05-26 12:19:54 +02:00
Prowler Bot 1a397d1024 fix(ui): avoid report preflight timeouts (#11362)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
2026-05-26 12:05:03 +02:00
Prowler Bot d9c849bed0 fix(az-m365): asyncio.run() in Azure/M365 Celery worker event (#11361)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-05-26 11:50:28 +02:00
Prowler Bot a33c301fcc fix(gcp): match enable-oslogin metadata case-insensitively (#11359)
Co-authored-by: Aline Almeida <aline@tuplita.ai>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-05-26 10:42:17 +02:00
Prowler Bot e65bf81bf8 fix(azure): require all SMB channel encryption algorithms to be secure (storage_smb_channel_encryption_with_secure_algorithm) (#11354)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-05-25 18:37:45 +02:00
Prowler Bot ea419b49d8 chore: changelog v5.28.1 (#11348)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-05-25 10:20:54 +02:00
Prowler Bot 5900d2314a chore(ui): add changelog for scan report fix (#11339)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-05-22 15:12:48 +02:00
Prowler Bot 3116352931 fix(ui): stream scan report downloads (#11337)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-05-22 15:05:40 +02:00
Prowler Bot d54bf452ca perf(api): speed up finding-groups endpoint for finding-level filters (#11336)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-05-22 14:17:49 +02:00
Prowler Bot 8d8f551664 chore(release): Bump versions to v5.28.1 (#11333)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-22 13:34:34 +02:00
Prowler Bot ae961e5065 chore(api): Update prowler dependency to v5.28 for release 5.28.0 (#11331)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-05-22 12:34:42 +02:00
32 changed files with 1042 additions and 118 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.28.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.28.2
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+8 -1
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.29.1] (Prowler v5.28.1)
### 🐞 Fixed
- `finding-groups` slow response with finding-level filters such as `region`; check title and description are now read from the daily summaries, which drops sorting by `check_title` [(#11326)](https://github.com/prowler-cloud/prowler/pull/11326)
---
## [1.29.0] (Prowler v5.28.0)
### 🚀 Added
@@ -29,7 +37,6 @@ All notable changes to the **Prowler API** are documented in this file.
- `perform_scan_task` and `perform_scheduled_scan_task` now short-circuit with a warning and `return None` when the target provider no longer exists, instead of letting `handle_provider_deletion` raise `ProviderDeletedException`. `perform_scheduled_scan_task` also removes any orphan `PeriodicTask` it finds so beat stops re-firing scans for deleted providers. Prevents queued messages for deleted providers from being recorded as `FAILURE` [(#11185)](https://github.com/prowler-cloud/prowler/pull/11185)
- 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)
+2 -2
View File
@@ -43,7 +43,7 @@ dependencies = [
"defusedxml==0.7.1",
"gunicorn==23.0.0",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.28",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -68,7 +68,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.29.0"
version = "1.29.2"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.29.0
version: 1.29.2
description: |-
Prowler API specification.
+20 -6
View File
@@ -15921,6 +15921,12 @@ class TestFindingGroupViewSet:
assert attrs["fail_count"] == 0
assert attrs["resources_total"] == 1
assert attrs["resources_fail"] == 0
# check_title / check_description are resolved post-pagination from the
# summary table, not from the finding's check_metadata.
assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs"
assert (
attrs["check_description"] == "EC2 instances should use private IPs only."
)
def test_finding_groups_status_pass_when_no_fail(
self, authenticated_client, finding_groups_fixture
@@ -17162,6 +17168,12 @@ class TestFindingGroupViewSet:
assert attrs["fail_count"] == 0
assert attrs["resources_total"] == 1
assert attrs["resources_fail"] == 0
# check_title / check_description are resolved post-pagination from the
# summary table, not from the finding's check_metadata.
assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs"
assert (
attrs["check_description"] == "EC2 instances should use private IPs only."
)
def test_finding_groups_latest_status_in_filter(
self, authenticated_client, finding_groups_fixture
@@ -17419,18 +17431,20 @@ class TestFindingGroupViewSet:
check_ids = [item["id"] for item in data]
assert check_ids == sorted(check_ids)
def test_finding_groups_latest_sort_by_check_title(
def test_finding_groups_latest_sort_by_check_title_not_supported(
self, authenticated_client, finding_groups_fixture
):
"""Test /latest supports sorting by check_title."""
"""check_title is not a sortable field for finding groups.
Titles live in the TOASTed check_metadata blob and are resolved after
pagination from the summary table, so they cannot drive DB-level
ordering. Requesting that sort is rejected.
"""
response = authenticated_client.get(
reverse("finding-group-latest"),
{"sort": "check_title"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()["data"]
check_titles = [item["attributes"]["check_title"] for item in data]
assert check_titles == sorted(check_titles)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.parametrize(
"endpoint_name", ["finding-group-list", "finding-group-latest"]
+39 -11
View File
@@ -7369,6 +7369,15 @@ class FindingGroupViewSet(BaseRLSViewSet):
output_field=IntegerField(),
)
# `check_title` / `check_description` are intentionally NOT resolved
# here. They live in the large JSONB `check_metadata` blob (TOASTed),
# so reading them per finding row is very expensive, and pulling them
# in via a correlated subquery makes Django add the subquery to GROUP
# BY, which re-evaluates it once per input row. They are identical for
# every finding of a `check_id`, so `_post_process_aggregation` fills
# them from the summary table's plain columns in a single batched
# lookup scoped to the paginated page.
# `pass_count`, `fail_count` and `manual_count` only count non-muted
# findings. Muted findings are tracked separately via the
# `*_muted_count` fields.
@@ -7439,15 +7448,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
agg_failing_since=Min(
"first_seen_at", filter=Q(status="FAIL", muted=False)
),
check_title=Coalesce(
Max(KeyTextTransform("checktitle", "check_metadata")),
Max(KeyTextTransform("CheckTitle", "check_metadata")),
Max(KeyTextTransform("Checktitle", "check_metadata")),
),
check_description=Coalesce(
Max(KeyTextTransform("description", "check_metadata")),
Max(KeyTextTransform("Description", "check_metadata")),
),
)
.annotate(
# Group is muted only if it has zero non-muted findings.
@@ -7503,9 +7503,38 @@ class FindingGroupViewSet(BaseRLSViewSet):
- Computes aggregated status (FAIL > PASS > MANUAL); the orthogonal
``muted`` boolean is already on the row from the SQL aggregation
- Converts provider string to list
- Fills check_title / check_description for the findings path
"""
rows = list(aggregated_data)
# The findings-aggregation path omits check_title / check_description
# (they sit in TOASTed JSONB; see _aggregate_findings). Fill them from
# the summary table's plain columns in one query scoped to this page.
# The summary-aggregation path already carries them, so skip it there.
if rows and "check_title" not in rows[0]:
check_ids = [row["check_id"] for row in rows]
role = get_role(self.request.user, self.request.tenant_id)
summaries = FindingGroupDailySummary.objects.filter(
tenant_id=self.request.tenant_id,
check_id__in=check_ids,
)
# Scope to the user's providers, mirroring get_queryset(), so titles
# are read only from providers the user can see.
if not role.unlimited_visibility:
summaries = summaries.filter(provider__in=get_providers(role))
metadata_by_check = {
item["check_id"]: item
for item in summaries.order_by("check_id", "-inserted_at")
.distinct("check_id")
.values("check_id", "check_title", "check_description")
}
for row in rows:
metadata = metadata_by_check.get(row["check_id"], {})
row["check_title"] = metadata.get("check_title")
row["check_description"] = metadata.get("check_description")
results = []
for row in aggregated_data:
for row in rows:
# Convert severity order back to string
severity_order = row.get("severity_order", 1)
row["severity"] = SEVERITY_ORDER_REVERSE.get(
@@ -7551,7 +7580,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
_FINDING_GROUP_SORT_MAP = {
"check_id": "check_id",
"check_title": "check_title",
"severity": "severity_order",
"status": "status_order",
"muted": "muted",
Generated
+5 -4
View File
@@ -4410,8 +4410,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.27.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
version = "5.28.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.28#3a096b17504fe8f3f743fdc44148d35b9723df92" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4484,6 +4484,7 @@ dependencies = [
{ name = "pygithub" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "scaleway" },
{ name = "schema" },
{ name = "shodan" },
{ name = "slack-sdk" },
@@ -4494,7 +4495,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.29.0"
version = "1.29.2"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -4590,7 +4591,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "neo4j", specifier = "==6.1.0" },
{ name = "openai", specifier = "==1.109.1" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.28" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
{ name = "reportlab", specifier = "==4.4.10" },
@@ -91,6 +91,7 @@ The following list includes all the Azure checks with configurable variables tha
| `sqlserver_recommended_minimal_tls_version` | `recommended_minimal_tls_versions` | List of Strings |
| `vm_sufficient_daily_backup_retention_period` | `vm_backup_min_daily_retention_days` | Integer |
| `vm_desired_sku_size` | `desired_vm_sku_sizes` | List of Strings |
| `storage_smb_channel_encryption_with_secure_algorithm` | `recommended_smb_channel_encryption_algorithms` | List of Strings |
| `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String |
| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_threshold` | Float |
| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_minutes` | Integer |
@@ -534,6 +535,18 @@ azure:
"1.3"
]
# Azure Storage
# azure.storage_smb_channel_encryption_with_secure_algorithm
# List of SMB channel encryption algorithms allowed on file shares. A storage
# account passes only if every enabled algorithm is in this list. Defaults to
# the value required by CIS (AES-256-GCM only, excluding weaker AES-128 ciphers).
recommended_smb_channel_encryption_algorithms:
[
"AES-256-GCM",
# "AES-128-CCM",
# "AES-128-GCM",
]
# Azure Virtual Machines
# azure.vm_desired_sku_size
# List of desired VM SKU sizes that are allowed in the organization
+10
View File
@@ -2,6 +2,14 @@
All notable changes to the **Prowler MCP Server** are documented in this file.
## [0.7.2] (Prowler v5.28.1)
### 🐞 Fixed
- Preserve authorization header in HTTP mode [(#11366)](https://github.com/prowler-cloud/prowler/pull/11366)
---
## [0.7.1] (Prowler v5.28.0)
### 🔐 Security
@@ -44,6 +52,8 @@ All notable changes to the **Prowler MCP Server** are documented in this file.
- Attack Path tool to get Neo4j DB schema [(#10321)](https://github.com/prowler-cloud/prowler/pull/10321)
---
## [0.4.0] (Prowler v5.19.0)
### 🚀 Added
@@ -5,6 +5,7 @@ from datetime import datetime
from typing import Dict, Optional
from fastmcp.server.dependencies import get_http_headers
from prowler_mcp_server import __version__
from prowler_mcp_server.lib.logger import logger
@@ -68,7 +69,7 @@ class ProwlerAppAuth:
async def authenticate(self) -> str:
"""Authenticate and return token (API key for STDIO, API key or JWT for HTTP)."""
if self.mode == "http":
headers = get_http_headers()
headers = get_http_headers(include={"authorization"})
authorization_header = headers.get("authorization", None)
if not authorization_header:
+10
View File
@@ -2,6 +2,16 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.28.1] (Prowler 5.28.1)
### 🐞 Fixed
- `compute_project_os_login_enabled` and `compute_project_os_login_2fa_enabled` checks for GCP provider no longer false-FAIL on projects where the `enable-oslogin` / `enable-oslogin-2fa` metadata is not set explicitly but is inherited automatically from the `constraints/compute.requireOsLogin` org policy. The policy controller writes the inherited value in lowercase (`"true"`), but the service-layer parser compared it to the uppercase string literal `"TRUE"`. Comparison is now case-insensitive [(#11341)](https://github.com/prowler-cloud/prowler/pull/11341)
- `storage_smb_channel_encryption_with_secure_algorithm` check for Azure provider no longer passes when a storage account allows a weak SMB channel encryption algorithm (e.g. `AES-128-CCM`/`AES-128-GCM`) alongside `AES-256-GCM`; it now requires every enabled algorithm to be in the recommended list, configurable via `azure.recommended_smb_channel_encryption_algorithms` (defaults to `AES-256-GCM` only, as required by CIS) [(#11327)](https://github.com/prowler-cloud/prowler/pull/11327)
- Azure and M365 providers crashing with `RuntimeError: There is no current event loop` on Python 3.12 when called from threads without an active event loop (e.g. Celery workers) [(#11360)](https://github.com/prowler-cloud/prowler/pull/11360)
---
## [5.28.0] (Prowler v5.28.0)
### 🚀 Added
+1 -1
View File
@@ -48,7 +48,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.28.0"
prowler_version = "5.28.2"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+12
View File
@@ -467,6 +467,18 @@ azure:
"1.3",
]
# Azure Storage
# azure.storage_smb_channel_encryption_with_secure_algorithm
# List of SMB channel encryption algorithms allowed on file shares. A storage
# account passes only if every enabled algorithm is in this list. Defaults to
# the value required by CIS (AES-256-GCM only, excluding weaker AES-128 ciphers).
recommended_smb_channel_encryption_algorithms:
[
"AES-256-GCM",
# "AES-128-CCM",
# "AES-128-GCM",
]
# Azure Virtual Machines
# azure.vm_desired_sku_size
# List of desired VM SKU sizes that are allowed in the organization
+1 -1
View File
@@ -949,7 +949,7 @@ class AzureProvider(Provider):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
asyncio.get_event_loop().run_until_complete(get_azure_identity())
asyncio.run(get_azure_identity())
# Managed identities only can be assigned resource, resource group and subscription scope permissions
elif managed_identity_auth:
@@ -34,5 +34,5 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check passes if SMB channel encryption is set to a secure algorithm."
"Notes": "This check passes only if every SMB channel encryption algorithm allowed on the file shares is in the recommended list, which is configurable via azure.recommended_smb_channel_encryption_algorithms and defaults to AES-256-GCM only, as required by CIS."
}
@@ -1,32 +1,38 @@
from prowler.lib.check.models import Check, Check_Report_Azure
from prowler.providers.azure.services.storage.storage_client import storage_client
SECURE_ENCRYPTION_ALGORITHMS = ["AES-256-GCM"]
DEFAULT_SECURE_ENCRYPTION_ALGORITHMS = ["AES-256-GCM"]
class storage_smb_channel_encryption_with_secure_algorithm(Check):
"""
Ensure SMB channel encryption for file shares is set to the recommended algorithm (AES-256-GCM or higher).
Ensure SMB channel encryption for file shares only allows secure algorithms (AES-256-GCM or higher by default).
The list of allowed algorithms is configurable via
azure.recommended_smb_channel_encryption_algorithms in the Prowler configuration file.
This check evaluates whether SMB file shares are configured to use only the recommended SMB channel encryption algorithms.
- PASS: Storage account has the recommended SMB channel encryption (AES-256-GCM or higher) enabled for file shares.
- FAIL: Storage account does not have the recommended SMB channel encryption enabled for file shares or uses an unsupported algorithm.
- PASS: Storage account only allows secure SMB channel encryption algorithms for file shares.
- FAIL: Storage account does not have SMB channel encryption enabled, or it allows at least one algorithm that is not in the recommended list.
"""
def execute(self) -> list[Check_Report_Azure]:
findings = []
secure_encryption_algorithms = storage_client.audit_config.get(
"recommended_smb_channel_encryption_algorithms",
DEFAULT_SECURE_ENCRYPTION_ALGORITHMS,
)
for subscription, storage_accounts in storage_client.storage_accounts.items():
subscription_name = storage_client.subscriptions.get(
subscription, subscription
)
for account in storage_accounts:
if account.file_service_properties:
channel_encryption = (
account.file_service_properties.smb_protocol_settings.channel_encryption
)
pretty_current_algorithms = (
", ".join(
account.file_service_properties.smb_protocol_settings.channel_encryption
)
if account.file_service_properties.smb_protocol_settings.channel_encryption
else "none"
", ".join(channel_encryption) if channel_encryption else "none"
)
report = Check_Report_Azure(
metadata=self.metadata(),
@@ -35,20 +41,18 @@ class storage_smb_channel_encryption_with_secure_algorithm(Check):
report.subscription = subscription
report.resource_name = account.name
if (
not account.file_service_properties.smb_protocol_settings.channel_encryption
):
if not channel_encryption:
report.status = "FAIL"
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) does not have SMB channel encryption enabled for file shares."
elif any(
algorithm in SECURE_ENCRYPTION_ALGORITHMS
for algorithm in account.file_service_properties.smb_protocol_settings.channel_encryption
elif all(
algorithm in secure_encryption_algorithms
for algorithm in channel_encryption
):
report.status = "PASS"
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) has a secure algorithm for SMB channel encryption ({', '.join(SECURE_ENCRYPTION_ALGORITHMS)}) enabled for file shares since it supports {pretty_current_algorithms}."
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) only allows secure algorithms for SMB channel encryption on file shares since it supports {pretty_current_algorithms}."
else:
report.status = "FAIL"
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) does not have SMB channel encryption with a secure algorithm for file shares since it supports {pretty_current_algorithms}."
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) allows insecure algorithms for SMB channel encryption on file shares since it supports {pretty_current_algorithms} and only {', '.join(secure_encryption_algorithms)} is recommended."
findings.append(report)
return findings
@@ -87,9 +87,15 @@ class Compute(GCPService):
.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
)
for item in response["commonInstanceMetadata"].get("items", []):
if item["key"] == "enable-oslogin" and item["value"] == "TRUE":
if (
item["key"] == "enable-oslogin"
and item["value"].lower() == "true"
):
enable_oslogin = True
if item["key"] == "enable-oslogin-2fa" and item["value"] == "TRUE":
if (
item["key"] == "enable-oslogin-2fa"
and item["value"].lower() == "true"
):
enable_oslogin_2fa = True
self.compute_projects.append(
Project(
+3 -7
View File
@@ -1073,7 +1073,7 @@ class M365Provider(Provider):
organization_info = await client.organization.get()
identity.tenant_id = organization_info.value[0].id
asyncio.get_event_loop().run_until_complete(get_m365_identity(identity))
asyncio.run(get_m365_identity(identity))
return identity
@staticmethod
@@ -1261,9 +1261,7 @@ class M365Provider(Provider):
result = await client.domains.get()
return result.value
result = asyncio.get_event_loop().run_until_complete(
verify_certificate()
)
result = asyncio.run(verify_certificate())
if not result:
raise M365NotValidCertificateContentError(
file=os.path.basename(__file__),
@@ -1284,9 +1282,7 @@ class M365Provider(Provider):
result = await client.domains.get()
return result.value
result = asyncio.get_event_loop().run_until_complete(
verify_certificate()
)
result = asyncio.run(verify_certificate())
if not result:
raise M365NotValidCertificatePathError(
file=os.path.basename(__file__),
+1 -1
View File
@@ -120,7 +120,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">=3.10,<3.13"
version = "5.28.0"
version = "5.28.2"
[project.scripts]
prowler = "prowler.__main__:prowler"
+88 -1
View File
@@ -1,4 +1,5 @@
from unittest.mock import patch
import asyncio
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
@@ -86,6 +87,7 @@ class TestAzureProvider:
"python_latest_version": "3.12",
"java_latest_version": "17",
"recommended_minimal_tls_versions": ["1.2", "1.3"],
"recommended_smb_channel_encryption_algorithms": ["AES-256-GCM"],
"vm_backup_min_daily_retention_days": 7,
"desired_vm_sku_sizes": [
"Standard_A8_v2",
@@ -721,3 +723,88 @@ class TestAzureProviderSetupIdentitySubscriptions:
first_id: shared_name,
second_id: shared_name,
}
class TestAzureProviderSetupIdentityEventLoop:
"""Regression for the Celery worker scenario where
asyncio.get_event_loop() raised "There is no current event loop in
thread 'MainThread'." on Python 3.12. setup_identity now uses
asyncio.run(), which creates its own loop and must work without a
pre-existing one in the current thread."""
@staticmethod
def _mock_subscription(display_name, subscription_id):
mock_subscription = MagicMock()
mock_subscription.display_name = display_name
mock_subscription.subscription_id = subscription_id
return mock_subscription
@staticmethod
def _build_subscriptions_client_mock(subscriptions):
subscriptions_operations = MagicMock()
subscriptions_operations.list = MagicMock(return_value=subscriptions)
subscriptions_operations.get = MagicMock()
tenants_operations = MagicMock()
tenants_operations.list = MagicMock(return_value=[])
client_instance = MagicMock()
client_instance.subscriptions = subscriptions_operations
client_instance.tenants = tenants_operations
return MagicMock(return_value=client_instance)
@staticmethod
def _build_provider():
with patch.object(AzureProvider, "__init__", return_value=None):
azure_provider = AzureProvider()
azure_provider._session = MagicMock()
azure_provider._region_config = AzureRegionConfig(
name="AzureCloud",
authority=None,
base_url="https://management.azure.com",
credential_scopes=["https://management.azure.com/.default"],
)
return azure_provider
def test_setup_identity_succeeds_without_active_event_loop(self):
sub_id = str(uuid4())
subscriptions_client = self._build_subscriptions_client_mock(
[self._mock_subscription("Sub", sub_id)]
)
graph_client = MagicMock()
graph_client.domains.get = AsyncMock(return_value=MagicMock(value=[]))
graph_client.me.get = AsyncMock(return_value=None)
# Simulate the Celery worker state: no event loop registered for the
# current thread. Before the fix this combination triggered
# `RuntimeError: There is no current event loop in thread 'MainThread'.`
# on Python 3.12 from asyncio.get_event_loop().
asyncio.set_event_loop(None)
try:
with (
patch(
"prowler.providers.azure.azure_provider.GraphServiceClient",
return_value=graph_client,
),
patch(
"prowler.providers.azure.azure_provider.SubscriptionClient",
subscriptions_client,
),
):
azure_provider = self._build_provider()
identity = azure_provider.setup_identity(
az_cli_auth=False,
sp_env_auth=True,
browser_auth=False,
managed_identity_auth=False,
subscription_ids=[],
client_id="00000000-0000-0000-0000-000000000000",
)
finally:
# Re-arm a loop for sibling tests that may rely on the default.
asyncio.set_event_loop(asyncio.new_event_loop())
assert isinstance(identity, AzureIdentityInfo)
assert identity.subscriptions == {sub_id: "Sub"}
graph_client.domains.get.assert_awaited_once()
@@ -20,6 +20,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
def test_no_storage_accounts(self):
storage_client = mock.MagicMock()
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
storage_client.audit_config = {}
storage_client.storage_accounts = {}
with (
mock.patch(
@@ -44,6 +45,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
storage_account_name = "Test Storage Account"
storage_client = mock.MagicMock()
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
storage_client.audit_config = {}
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
@@ -97,6 +99,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
)
storage_client = mock.MagicMock()
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
storage_client.audit_config = {}
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
@@ -154,6 +157,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
)
storage_client = mock.MagicMock()
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
storage_client.audit_config = {}
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
@@ -194,7 +198,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == (
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have SMB channel encryption with a secure algorithm for file shares since it supports AES-128-GCM."
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows insecure algorithms for SMB channel encryption on file shares since it supports AES-128-GCM and only AES-256-GCM is recommended."
)
def test_recommended_encryption(self):
@@ -211,6 +215,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
)
storage_client = mock.MagicMock()
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
storage_client.audit_config = {}
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
@@ -251,5 +256,126 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has a secure algorithm for SMB channel encryption (AES-256-GCM) enabled for file shares since it supports AES-256-GCM."
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} only allows secure algorithms for SMB channel encryption on file shares since it supports AES-256-GCM."
)
def test_recommended_algorithm_mixed_with_weak_algorithm(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account"
file_service_properties = FileServiceProperties(
id="id1",
name="fs1",
type="type1",
share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7),
smb_protocol_settings=SMBProtocolSettings(
channel_encryption=["AES-128-CCM", "AES-256-GCM"], supported_versions=[]
),
)
storage_client = mock.MagicMock()
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
storage_client.audit_config = {}
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id=storage_account_id,
name=storage_account_name,
resouce_group_name="rg",
enable_https_traffic_only=False,
infrastructure_encryption=False,
allow_blob_public_access=False,
network_rule_set=NetworkRuleSet(
bypass="AzureServices", default_action="Allow"
),
encryption_type="None",
minimum_tls_version="TLS1_2",
key_expiration_period_in_days=None,
location="westeurope",
private_endpoint_connections=[],
file_service_properties=file_service_properties,
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm.storage_client",
new=storage_client,
),
):
from prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm import (
storage_smb_channel_encryption_with_secure_algorithm,
)
check = storage_smb_channel_encryption_with_secure_algorithm()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == (
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows insecure algorithms for SMB channel encryption on file shares since it supports AES-128-CCM, AES-256-GCM and only AES-256-GCM is recommended."
)
def test_custom_recommended_algorithms_from_config(self):
storage_account_id = str(uuid4())
storage_account_name = "Test Storage Account"
file_service_properties = FileServiceProperties(
id="id1",
name="fs1",
type="type1",
share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7),
smb_protocol_settings=SMBProtocolSettings(
channel_encryption=["AES-128-GCM", "AES-256-GCM"], supported_versions=[]
),
)
storage_client = mock.MagicMock()
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
storage_client.audit_config = {
"recommended_smb_channel_encryption_algorithms": [
"AES-128-GCM",
"AES-256-GCM",
]
}
storage_client.storage_accounts = {
AZURE_SUBSCRIPTION_ID: [
Account(
id=storage_account_id,
name=storage_account_name,
resouce_group_name="rg",
enable_https_traffic_only=False,
infrastructure_encryption=False,
allow_blob_public_access=False,
network_rule_set=NetworkRuleSet(
bypass="AzureServices", default_action="Allow"
),
encryption_type="None",
minimum_tls_version="TLS1_2",
key_expiration_period_in_days=None,
location="westeurope",
private_endpoint_connections=[],
file_service_properties=file_service_properties,
)
]
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm.storage_client",
new=storage_client,
),
):
from prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm import (
storage_smb_channel_encryption_with_secure_algorithm,
)
check = storage_smb_channel_encryption_with_secure_algorithm()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == (
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} only allows secure algorithms for SMB channel encryption on file shares since it supports AES-128-GCM, AES-256-GCM."
)
+9 -1
View File
@@ -126,7 +126,11 @@ def mock_api_projects_calls(client: MagicMock):
"etag": "BwWWja0YfJA=",
"version": 3,
}
# Used by compute client and cloudresourcemanager
# Used by compute client and cloudresourcemanager.
# `enable-oslogin` covers the documented uppercase form (TRUE);
# `enable-oslogin-2fa` covers the lowercase form (true) that GCP's
# `constraints/compute.requireOsLogin` org-policy controller writes
# in production. The service-layer parser must handle both casings.
client.projects().get().execute.return_value = {
"projectNumber": "123456789012",
"commonInstanceMetadata": {
@@ -139,6 +143,10 @@ def mock_api_projects_calls(client: MagicMock):
"key": "enable-oslogin",
"value": "FALSE",
},
{
"key": "enable-oslogin-2fa",
"value": "true",
},
{
"key": "testing-key",
"value": "TRUE",
@@ -34,6 +34,7 @@ class TestComputeService:
assert len(compute_client.compute_projects) == 1
assert compute_client.compute_projects[0].id == GCP_PROJECT_ID
assert compute_client.compute_projects[0].enable_oslogin
assert compute_client.compute_projects[0].enable_oslogin_2fa
assert len(compute_client.instances) == 2
assert compute_client.instances[0].name == "instance1"
+107 -23
View File
@@ -1,6 +1,7 @@
import asyncio
import base64
import os
from unittest.mock import MagicMock, mock_open, patch
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
from uuid import uuid4
import pytest
@@ -1535,19 +1536,17 @@ class TestM365Provider:
TENANT_ID, CLIENT_ID, None, b"fake_certificate_data", certificate_path
)
@patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop")
@patch("prowler.providers.m365.m365_provider.asyncio.run")
@patch("prowler.providers.m365.m365_provider.GraphServiceClient")
@patch("prowler.providers.m365.m365_provider.CertificateCredential")
def test_verify_client_certificate_content_success(
self, mock_cert_cred, mock_graph, mock_loop
self, mock_cert_cred, mock_graph, mock_asyncio_run
):
"""Test verify_client method with valid certificate content"""
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
# Mock the async call
mock_loop_instance = MagicMock()
mock_loop.return_value = mock_loop_instance
mock_loop_instance.run_until_complete.return_value = [{"id": "domain.com"}]
# Mock the async call result
mock_asyncio_run.return_value = [{"id": "domain.com"}]
# Mock credential and graph client
mock_credential = MagicMock()
@@ -1563,19 +1562,17 @@ class TestM365Provider:
mock_cert_cred.assert_called_once()
mock_graph.assert_called_once_with(credentials=mock_credential)
@patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop")
@patch("prowler.providers.m365.m365_provider.asyncio.run")
@patch("prowler.providers.m365.m365_provider.GraphServiceClient")
@patch("prowler.providers.m365.m365_provider.CertificateCredential")
def test_verify_client_certificate_content_failure(
self, mock_cert_cred, mock_graph, mock_loop
self, mock_cert_cred, mock_graph, mock_asyncio_run
):
"""Test verify_client method with certificate content that fails validation"""
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
# Mock the async call to return empty result (invalid certificate)
mock_loop_instance = MagicMock()
mock_loop.return_value = mock_loop_instance
mock_loop_instance.run_until_complete.return_value = None
mock_asyncio_run.return_value = None
# Mock credential and graph client
mock_credential = MagicMock()
@@ -1591,19 +1588,17 @@ class TestM365Provider:
assert "certificate content is not valid" in str(exception.value)
@patch("builtins.open", mock_open(read_data=b"fake_certificate_data"))
@patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop")
@patch("prowler.providers.m365.m365_provider.asyncio.run")
@patch("prowler.providers.m365.m365_provider.GraphServiceClient")
@patch("prowler.providers.m365.m365_provider.CertificateCredential")
def test_verify_client_certificate_path_success(
self, mock_cert_cred, mock_graph, mock_loop
self, mock_cert_cred, mock_graph, mock_asyncio_run
):
"""Test verify_client method with valid certificate path"""
certificate_path = "/path/to/cert.pem"
# Mock the async call
mock_loop_instance = MagicMock()
mock_loop.return_value = mock_loop_instance
mock_loop_instance.run_until_complete.return_value = [{"id": "domain.com"}]
# Mock the async call result
mock_asyncio_run.return_value = [{"id": "domain.com"}]
# Mock credential and graph client
mock_credential = MagicMock()
@@ -1618,19 +1613,17 @@ class TestM365Provider:
mock_graph.assert_called_once_with(credentials=mock_credential)
@patch("builtins.open", mock_open(read_data=b"fake_certificate_data"))
@patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop")
@patch("prowler.providers.m365.m365_provider.asyncio.run")
@patch("prowler.providers.m365.m365_provider.GraphServiceClient")
@patch("prowler.providers.m365.m365_provider.CertificateCredential")
def test_verify_client_certificate_path_failure(
self, mock_cert_cred, mock_graph, mock_loop
self, mock_cert_cred, mock_graph, mock_asyncio_run
):
"""Test verify_client method with certificate path that fails validation"""
certificate_path = "/path/to/cert.pem"
# Mock the async call to return empty result (invalid certificate)
mock_loop_instance = MagicMock()
mock_loop.return_value = mock_loop_instance
mock_loop_instance.run_until_complete.return_value = None
mock_asyncio_run.return_value = None
# Mock credential and graph client
mock_credential = MagicMock()
@@ -1804,3 +1797,94 @@ class TestM365Provider:
assert "Missing environment variable M365_CERTIFICATE_CONTENT" in str(
exception.value
)
class TestM365ProviderEventLoop:
"""Regression for Celery workers on Python 3.12 where
asyncio.get_event_loop() raised
`RuntimeError: There is no current event loop in thread 'MainThread'.`
M365Provider.setup_identity and M365Provider.validate_static_credentials
must work without a pre-existing loop in the current thread."""
def _without_event_loop(self, callable_):
# Simulate the Celery worker state: no event loop registered for the
# current thread.
asyncio.set_event_loop(None)
try:
return callable_()
finally:
# Re-arm a loop so sibling tests that rely on the default don't
# bleed into each other.
asyncio.set_event_loop(asyncio.new_event_loop())
def test_setup_identity_succeeds_without_active_event_loop(self):
domain = MagicMock()
domain.id = "tenant.onmicrosoft.com"
domain.is_default = True
org = MagicMock()
org.id = TENANT_ID
graph_client = MagicMock()
graph_client.domains.get = AsyncMock(return_value=MagicMock(value=[domain]))
graph_client.organization.get = AsyncMock(return_value=MagicMock(value=[org]))
session = MagicMock()
# `setup_identity` reads `session.credentials[0]._credential.client_id`
# when sp_env_auth is True to populate identity.identity_id.
session.credentials = [MagicMock()]
session.credentials[0]._credential.client_id = CLIENT_ID
def call():
with patch(
"prowler.providers.m365.m365_provider.GraphServiceClient",
return_value=graph_client,
):
return M365Provider.setup_identity(
sp_env_auth=True,
browser_auth=False,
az_cli_auth=False,
certificate_auth=False,
session=session,
)
identity = self._without_event_loop(call)
assert isinstance(identity, M365IdentityInfo)
assert identity.tenant_id == TENANT_ID
graph_client.domains.get.assert_awaited_once()
graph_client.organization.get.assert_awaited_once()
def test_verify_client_certificate_content_without_active_event_loop(self):
# `verify_client` is the function the Sentry trace exercises through
# certificate-based credential validation; it must run an asyncio
# coroutine to call `client.domains.get()` and previously relied on
# `asyncio.get_event_loop()`.
graph_client = MagicMock()
graph_client.domains.get = AsyncMock(
return_value=MagicMock(value=[MagicMock()])
)
def call():
with (
patch("prowler.providers.m365.m365_provider.CertificateCredential"),
patch(
"prowler.providers.m365.m365_provider.GraphServiceClient",
return_value=graph_client,
),
patch(
"prowler.providers.m365.m365_provider.base64.b64decode",
return_value=b"cert-bytes",
),
):
M365Provider.verify_client(
tenant_id=TENANT_ID,
client_id=CLIENT_ID,
client_secret=None,
certificate_content="dGVzdA==",
certificate_path=None,
)
# Must not raise "There is no current event loop in thread 'MainThread'.".
self._without_event_loop(call)
graph_client.domains.get.assert_awaited_once()
+9
View File
@@ -2,6 +2,15 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.28.1] (Prowler v5.28.1)
### 🐞 Fixed
- Large scan report ZIP downloads now stream through a Next.js Route Handler instead of buffering the full file in a Server Action [(#11330)](https://github.com/prowler-cloud/prowler/pull/11330)
- Compliance requirement findings table now respects the page size selector [(#11365)](https://github.com/prowler-cloud/prowler/pull/11365)
---
## [1.28.0] (Prowler v5.28.0)
### 🚀 Added
@@ -0,0 +1,202 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GET } from "./route";
const { getAuthHeadersMock } = vi.hoisted(() => ({
getAuthHeadersMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: getAuthHeadersMock,
}));
describe("GET /api/scans/[scanId]/report", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("streams the upstream report body without buffering it", async () => {
const upstreamBody = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
});
const fetchMock = vi.fn().mockResolvedValue(
new Response(upstreamBody, {
status: 200,
headers: {
"content-type": "application/zip",
"content-length": "3",
},
}),
);
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
const response = await GET(new Request("http://localhost/api"), {
params: Promise.resolve({ scanId: "scan-123" }),
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/scans/scan-123/report",
expect.objectContaining({
headers: { Authorization: "Bearer token" },
cache: "no-store",
redirect: "manual",
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/zip");
expect(response.headers.get("content-length")).toBe("3");
expect(response.headers.get("content-disposition")).toBe(
'attachment; filename="scan-scan-123-report.zip"',
);
expect(response.body).toBe(upstreamBody);
});
it("checks report readiness without streaming ready report bytes", async () => {
const cancelMock = vi.fn();
const upstreamBody = new ReadableStream({
cancel: cancelMock,
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
},
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(upstreamBody, { status: 200 })),
);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
const response = await GET(
new Request("http://localhost/api?preflight=1"),
{
params: Promise.resolve({ scanId: "scan-123" }),
},
);
expect(response.status).toBe(204);
expect(response.body).toBeNull();
expect(cancelMock).toHaveBeenCalledTimes(1);
});
it("redirects the browser to the presigned URL for S3-backed reports", async () => {
const presignedUrl = "https://bucket.s3.example.com/report.zip?sig=abc";
const fetchMock = vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: presignedUrl },
}),
);
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
const response = await GET(new Request("http://localhost/api"), {
params: Promise.resolve({ scanId: "scan-123" }),
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/scans/scan-123/report",
expect.objectContaining({ redirect: "manual" }),
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(presignedUrl);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.body).toBeNull();
});
it("reports readiness without exposing the presigned URL on preflight", async () => {
const presignedUrl = "https://bucket.s3.example.com/report.zip?sig=abc";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: presignedUrl },
}),
),
);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
const response = await GET(
new Request("http://localhost/api?preflight=1"),
{
params: Promise.resolve({ scanId: "scan-123" }),
},
);
expect(response.status).toBe(204);
expect(response.headers.get("location")).toBeNull();
expect(response.body).toBeNull();
});
it("preserves pending report responses from the API", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
Response.json({ data: { id: "task-1" } }, { status: 202 }),
),
);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
const response = await GET(new Request("http://localhost/api"), {
params: Promise.resolve({ scanId: "scan-123" }),
});
expect(response.status).toBe(202);
await expect(response.json()).resolves.toEqual({ data: { id: "task-1" } });
});
it("continues to the browser-native download when preflight times out", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new DOMException("Timed out", "TimeoutError")),
);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
const response = await GET(
new Request("http://localhost/api?preflight=1"),
{
params: Promise.resolve({ scanId: "scan-123" }),
},
);
expect(response.status).toBe(204);
expect(response.body).toBeNull();
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("does not forward upstream HTML error pages for preflight failures", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
"<html><body><h1>504 Gateway Time-out</h1></body></html>",
{
status: 504,
headers: { "content-type": "text/html" },
},
),
),
);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
const response = await GET(
new Request("http://localhost/api?preflight=1"),
{
params: Promise.resolve({ scanId: "scan-123" }),
},
);
expect(response.status).toBe(504);
expect(response.headers.get("content-type")).toContain("text/plain");
await expect(response.text()).resolves.toBe(
"Unable to prepare the scan report. Please try again in a few minutes.",
);
});
});
+160
View File
@@ -0,0 +1,160 @@
import { NextResponse } from "next/server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
interface ScanReportRouteContext {
params: Promise<{
scanId: string;
}>;
}
const COPY_RESPONSE_HEADERS = [
"content-length",
"content-type",
"etag",
"last-modified",
] as const;
const PREFLIGHT_TIMEOUT_MS = 10_000;
const REPORT_PREPARATION_ERROR =
"Unable to prepare the scan report. Please try again in a few minutes.";
const buildAttachmentFilename = (scanId: string) =>
`scan-${scanId.replace(/[^a-zA-Z0-9._-]/g, "-")}-report.zip`;
const buildDownloadHeaders = (upstreamHeaders: Headers, scanId: string) => {
const headers = new Headers({
"Cache-Control": "no-store",
"Content-Disposition": `attachment; filename="${buildAttachmentFilename(scanId)}"`,
});
COPY_RESPONSE_HEADERS.forEach((headerName) => {
const value = upstreamHeaders.get(headerName);
if (value) headers.set(headerName, value);
});
if (!headers.has("content-type")) {
headers.set("content-type", "application/zip");
}
return headers;
};
const isAbortError = (error: unknown) =>
error instanceof DOMException &&
(error.name === "AbortError" || error.name === "TimeoutError");
const isHtmlResponse = (headers: Headers) =>
headers.get("content-type")?.toLowerCase().includes("text/html") ?? false;
const isRedirect = (status: number) => status >= 300 && status < 400;
const preflightReadyResponse = () =>
new Response(null, {
status: 204,
headers: { "Cache-Control": "no-store" },
});
export async function GET(
request: Request,
{ params }: ScanReportRouteContext,
) {
const { scanId } = await params;
const headers = await getAuthHeaders({ contentType: false });
const upstreamUrl = `${apiBaseUrl}/scans/${encodeURIComponent(scanId)}/report`;
const isPreflight =
new URL(request.url).searchParams.get("preflight") === "1";
let upstreamResponse: Response;
try {
upstreamResponse = await fetch(upstreamUrl, {
headers,
cache: "no-store",
// The API redirects S3-backed reports to a presigned URL; keep that
// redirect instead of following it so the bytes never stream through
// this server.
redirect: "manual",
signal: isPreflight
? AbortSignal.timeout(PREFLIGHT_TIMEOUT_MS)
: undefined,
});
} catch (error) {
if (isPreflight && isAbortError(error)) {
return preflightReadyResponse();
}
throw error;
}
if (upstreamResponse.status === 202) {
const body = await upstreamResponse.json().catch(() => ({}));
return NextResponse.json(body, {
status: 202,
headers: { "Cache-Control": "no-store" },
});
}
// S3-backed reports: hand the API's presigned redirect to the browser so it
// downloads straight from S3 without proxying the bytes through this server.
if (isRedirect(upstreamResponse.status)) {
if (isPreflight) {
return preflightReadyResponse();
}
const location = upstreamResponse.headers.get("location");
if (!location) {
return NextResponse.json(
{ error: "Report redirect did not include a location." },
{ status: 502, headers: { "Cache-Control": "no-store" } },
);
}
return new Response(null, {
status: 307,
headers: { Location: location, "Cache-Control": "no-store" },
});
}
if (!upstreamResponse.ok) {
const body =
isPreflight && isHtmlResponse(upstreamResponse.headers)
? REPORT_PREPARATION_ERROR
: await upstreamResponse.text().catch(() => "");
return new Response(body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: {
"Cache-Control": "no-store",
"Content-Type":
isPreflight && isHtmlResponse(upstreamResponse.headers)
? "text/plain"
: upstreamResponse.headers.get("content-type") || "text/plain",
},
});
}
// Self-hosted without S3: the API returns the bytes directly, so there is no
// presigned URL to redirect to and we stream the response through instead.
if (isPreflight) {
await upstreamResponse.body?.cancel();
return preflightReadyResponse();
}
if (!upstreamResponse.body) {
return NextResponse.json(
{ error: "Report response did not include a readable body." },
{ status: 502, headers: { "Cache-Control": "no-store" } },
);
}
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: buildDownloadHeaders(upstreamResponse.headers, scanId),
});
}
@@ -32,10 +32,12 @@ export const ClientAccordionContent = ({
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
const searchParams = useSearchParams();
const pageNumber = searchParams.get("page") || "1";
const pageSize = searchParams.get("pageSize") || "10";
const complianceId = searchParams.get("complianceId");
const openFindingId = searchParams.get("id");
const sort = searchParams.get("sort") || FINDINGS_DEFAULT_SORT;
const loadedPageRef = useRef<string | null>(null);
const loadedPageSizeRef = useRef<string | null>(null);
const loadedSortRef = useRef<string | null>(null);
const loadedMutedRef = useRef<string | null>(null);
const isExpandedRef = useRef(false);
@@ -52,11 +54,13 @@ export const ClientAccordionContent = ({
requirement.check_ids?.length > 0 &&
requirement.status !== "No findings" &&
(loadedPageRef.current !== pageNumber ||
loadedPageSizeRef.current !== pageSize ||
loadedSortRef.current !== sort ||
loadedMutedRef.current !== mutedFilter ||
!isExpandedRef.current)
) {
loadedPageRef.current = pageNumber;
loadedPageSizeRef.current = pageSize;
loadedSortRef.current = sort;
loadedMutedRef.current = mutedFilter;
isExpandedRef.current = true;
@@ -72,6 +76,7 @@ export const ClientAccordionContent = ({
...(region && { "filter[region__in]": region }),
},
page: parseInt(pageNumber, 10),
pageSize: parseInt(pageSize, 10),
sort: encodedSort,
});
@@ -111,6 +116,7 @@ export const ClientAccordionContent = ({
requirement,
scanId,
pageNumber,
pageSize,
sort,
region,
mutedFilter,
@@ -30,7 +30,9 @@ describe("resource detail content", () => {
it("renders the external resource link below the resource title row", () => {
expect(source).toContain(`</div>
<ExternalResourceLink`);
expect(source).toContain('className="self-start justify-start"');
expect(source).toMatch(
/className="(?:self-start justify-start|justify-start self-start)"/,
);
});
it("keeps resource date fields together on the third details row", () => {
+120
View File
@@ -0,0 +1,120 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { downloadScanZip } from "./helper";
vi.mock("@/actions/scans", () => ({
getComplianceCsv: vi.fn(),
getCompliancePdfReport: vi.fn(),
}));
vi.mock("@/actions/task", () => ({
getTask: vi.fn(),
}));
vi.mock("@/auth.config", () => ({
auth: vi.fn(),
}));
const createToast = () => vi.fn();
const getAnchor = () => {
const anchor = document.createElement("a");
const clickMock = vi.spyOn(anchor, "click").mockImplementation(() => {});
vi.spyOn(document, "createElement").mockReturnValue(anchor);
return { anchor, clickMock };
};
describe("downloadScanZip", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
document.body.replaceChildren();
});
it("preflights the report and starts a browser-native download when ready", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(null, { status: 204 })),
);
const { anchor, clickMock } = getAnchor();
const toast = createToast();
await downloadScanZip("scan-123", toast);
expect(fetch).toHaveBeenCalledWith(
"/api/scans/scan-123/report?preflight=1",
{
cache: "no-store",
},
);
expect(anchor.href).toContain("/api/scans/scan-123/report");
expect(anchor.download).toBe("scan-scan-123-report.zip");
expect(clickMock).toHaveBeenCalledTimes(1);
expect(toast).toHaveBeenCalledWith({
title: "Download Started",
description: "Your browser is downloading the scan report.",
});
});
it("shows the pending report message without starting a download", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response("{}", { status: 202 })),
);
const { clickMock } = getAnchor();
const toast = createToast();
await downloadScanZip("scan-123", toast);
expect(clickMock).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith({
title: "The report is still being generated",
description: "Please try again in a few minutes.",
});
});
it("shows an error without starting a download when preflight fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response("not found", { status: 404 })),
);
const { clickMock } = getAnchor();
const toast = createToast();
await downloadScanZip("scan-123", toast);
expect(clickMock).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith({
variant: "destructive",
title: "Download Failed",
description: "not found",
});
});
it("shows a generic error when preflight fails with an HTML gateway page", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
"<html><body><h1>504 Gateway Time-out</h1></body></html>",
{
status: 504,
headers: { "content-type": "text/html" },
},
),
),
);
const { clickMock } = getAnchor();
const toast = createToast();
await downloadScanZip("scan-123", toast);
expect(clickMock).not.toHaveBeenCalled();
expect(toast).toHaveBeenCalledWith({
variant: "destructive",
title: "Download Failed",
description:
"Unable to prepare the scan report. Please try again in a few minutes.",
});
});
});
+49 -32
View File
@@ -1,7 +1,6 @@
import {
getComplianceCsv,
getCompliancePdfReport,
getExportsZip,
type ScanBinaryResult,
} from "@/actions/scans";
import { getTask } from "@/actions/task";
@@ -102,48 +101,66 @@ export const getAuthUrl = (provider: AuthSocialProvider) => {
return url.toString();
};
const REPORT_PREPARATION_ERROR =
"Unable to prepare the scan report. Please try again in a few minutes.";
const getPreflightErrorMessage = async (response: Response) => {
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
if (contentType.includes("text/html")) {
return REPORT_PREPARATION_ERROR;
}
return (await response.text()) || "An unknown error occurred.";
};
export const downloadScanZip = async (
scanId: string,
toast: ReturnType<typeof useToast>["toast"],
) => {
const result = await getExportsZip(scanId);
const reportUrl = `/api/scans/${encodeURIComponent(scanId)}/report`;
if (result?.pending) {
try {
const preflightResponse = await fetch(`${reportUrl}?preflight=1`, {
cache: "no-store",
});
if (preflightResponse.status === 202) {
toast({
title: "The report is still being generated",
description: "Please try again in a few minutes.",
});
return;
}
if (!preflightResponse.ok) {
toast({
variant: "destructive",
title: "Download Failed",
description: await getPreflightErrorMessage(preflightResponse),
});
return;
}
} catch (_error) {
toast({
title: "The report is still being generated",
description: "Please try again in a few minutes.",
variant: "destructive",
title: "Download Failed",
description: "Unable to start the report download. Please try again.",
});
return;
}
if (result?.success && result.data) {
const binaryString = window.atob(result.data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const a = document.createElement("a");
a.href = reportUrl;
a.download = `scan-${scanId}-report.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
const blob = new Blob([bytes], { type: "application/zip" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = result.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast({
title: "Download Complete",
description: "Your scan report has been downloaded successfully.",
});
} else {
toast({
variant: "destructive",
title: "Download Failed",
description: result?.error || "An unknown error occurred.",
});
}
toast({
title: "Download Started",
description: "Your browser is downloading the scan report.",
});
};
/**
Generated
+1 -1
View File
@@ -3241,7 +3241,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.28.0"
version = "5.28.2"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },