mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-09 21:04:53 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa729a9d2d | |||
| d086a624a0 | |||
| a7c2b6cbce | |||
| 5da5848509 | |||
| 1a397d1024 | |||
| d9c849bed0 | |||
| a33c301fcc | |||
| e65bf81bf8 | |||
| ea419b49d8 | |||
| 5900d2314a | |||
| 3116352931 | |||
| d54bf452ca | |||
| 8d8f551664 | |||
| ae961e5065 |
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.29.0
|
||||
version: 1.29.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -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."
|
||||
}
|
||||
|
||||
+21
-17
@@ -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(
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
+128
-2
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user