Compare commits

..

18 Commits

Author SHA1 Message Date
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
Alejandro Bailo 3a096b1750 refactor(ui): improve resource detail and tab UX (#11325) 2026-05-22 12:03:03 +02:00
Daniel Barranquero 6f01041178 docs: add okta provider prowler cloud onboarding (#11322) 2026-05-22 10:29:59 +02:00
Pepe Fagoaga 13e2ede763 chore(changelog): prepare for v5.28.0 (#11321) 2026-05-22 09:33:40 +02:00
Pedro Martín c53ddfd532 fix(ui): resource tab scroll from container (#11320) 2026-05-22 09:13:57 +02:00
Pepe Fagoaga f86bd7b52e fix(sdk): absolute ENTRYPOINT to work with uv (#11313) 2026-05-22 08:25:59 +02:00
72 changed files with 1497 additions and 1056 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.1
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+1 -1
View File
@@ -100,4 +100,4 @@ RUN pip uninstall dash-html-components -y && \
pip uninstall dash-core-components -y
USER prowler
ENTRYPOINT [".venv/bin/prowler"]
ENTRYPOINT ["/home/prowler/.venv/bin/prowler"]
+10 -3
View File
@@ -2,12 +2,20 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.29.0] (Prowler UNRELEASED)
## [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
- `okta` provider support [(#11184)](https://github.com/prowler-cloud/prowler/pull/11184)
- `metadata` field on resources included in finding responses (`?include=resources`), so finding consumers can read the affected resource's metadata without an extra request [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187)
- `resource.metadata` attribute included in `/api/v1/findings?include=resources` [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187)
---
@@ -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.1"
[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.1
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.1"
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" },
-13
View File
@@ -438,19 +438,6 @@ mainConfig:
# Minimum number of Availability Zones that an ELBv2 must be in
elbv2_min_azs: 2
# AWS Post-Quantum TLS Configuration
# aws.acmpca_certificate_authority_pqc_key_algorithm
acmpca_pqc_key_algorithms:
- "ML_DSA_44"
- "ML_DSA_65"
- "ML_DSA_87"
# aws.rolesanywhere_trust_anchor_pqc_pki
rolesanywhere_pqc_pca_key_algorithms:
- "ML_DSA_44"
- "ML_DSA_65"
- "ML_DSA_87"
# AWS Secrets Configuration
# Patterns to ignore in the secrets checks
@@ -55,8 +55,6 @@ The following list includes all the AWS checks with configurable variables that
| `elasticache_redis_cluster_backup_enabled` | `minimum_snapshot_retention_period` | Integer |
| `elb_is_in_multiple_az` | `elb_min_azs` | Integer |
| `elbv2_is_in_multiple_az` | `elbv2_min_azs` | Integer |
| `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings |
| `rolesanywhere_trust_anchor_pqc_pki` | `rolesanywhere_pqc_pca_key_algorithms` | List of Strings |
| `guardduty_is_enabled` | `mute_non_default_regions` | Boolean |
| `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer |
| `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer |
@@ -93,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 |
@@ -536,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
@@ -26,10 +26,51 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid
## Prowler Cloud
<VersionBadge version="5.28.0" />
### Step 1: Add the Provider
1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app).
2. Navigate to "Configuration" > "Providers".
![Providers Page](/images/prowler-app/cloud-providers-page.png)
3. Click "Add Provider".
![Add a Provider](/images/prowler-app/add-cloud-provider.png)
4. Select "Okta".
![Select Okta](/user-guide/providers/okta/images/select-okta-provider.png)
5. Enter the **Org Domain** of the target Okta organization and an optional alias, then click "Next".
![Add Okta Org Domain](/user-guide/providers/okta/images/okta-org-domain-form.png)
<Note>
Prowler Cloud onboarding for Okta is coming soon. Track the [Prowler GitHub repository](https://github.com/prowler-cloud/prowler) for release updates. Use the [Prowler CLI](#prowler-cli) workflow below in the meantime.
The Org Domain must be the bare hostname of an Okta-managed organization — for example, `acme.okta.com`, `acme.oktapreview.com`, `acme.okta-emea.com`, `acme.okta-gov.com`, `acme.okta.mil`, `acme.okta-miltest.com`, or `acme.trex-govcloud.com`. Omit the `https://` scheme, any path, and any trailing slash.
</Note>
### Step 2: Provide Credentials
Prowler Cloud authenticates to Okta with the **OAuth 2.0 Private Key JWT** flow exposed by an Okta **API Services** app. The service application, keypair, scope grants, and Read-Only Administrator role are set up once in the Okta Admin Console — full instructions are in the [Okta Authentication](/user-guide/providers/okta/authentication) guide.
1. Enter the **Client ID** of the Okta API Services app (for example, `0oa123456789abcdef`).
2. Paste the **Private Key** whose matching public key (JWK) is registered on the service app. Both PEM-encoded RSA keys and JWK JSON documents are accepted.
3. Click "Next".
![Okta Credentials Form](/user-guide/providers/okta/images/okta-credentials-form.png)
<Note>
The private key is transmitted over TLS and stored as an encrypted secret in the backend. Rotate or revoke the matching public key from the Okta Admin Console at any time to invalidate the credential without changes on the Prowler side.
</Note>
### Step 3: Launch the Scan
1. Review the connection summary. Prowler Cloud runs a credential probe against the Okta Management API before saving — a failed probe surfaces the underlying Okta error (`invalid_scope`, `Forbidden`, invalid credentials, etc.) so the configuration can be corrected before the first scan.
2. Choose the scan schedule: run a single scan or set up daily scans (every 24 hours).
3. Click **Launch Scan** to start auditing the Okta organization.
---
## Prowler CLI
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

+11 -1
View File
@@ -2,7 +2,15 @@
All notable changes to the **Prowler MCP Server** are documented in this file.
## [0.7.1] (Prowler UNRELEASED)
## [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:
@@ -36,7 +36,6 @@
"lightsail:GetRelationalDatabases",
"macie2:GetMacieSession",
"macie2:GetAutomatedDiscoveryConfiguration",
"rolesanywhere:ListTrustAnchors",
"s3:GetAccountPublicAccessBlock",
"shield:DescribeProtection",
"shield:GetSubscriptionState",
@@ -129,7 +129,6 @@ Resources:
- "lightsail:GetRelationalDatabases"
- "macie2:GetMacieSession"
- "macie2:GetAutomatedDiscoveryConfiguration"
- "rolesanywhere:ListTrustAnchors"
- "s3:GetAccountPublicAccessBlock"
- "shield:DescribeProtection"
- "shield:GetSubscriptionState"
+12 -3
View File
@@ -2,12 +2,20 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.28.0] (Prowler UNRELEASED)
## [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
- `acmpca_certificate_authority_pqc_key_algorithm` check and new `acmpca` service for AWS provider to verify AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm [(#11318)](https://github.com/prowler-cloud/prowler/pull/11318)
- `rolesanywhere_trust_anchor_pqc_pki` check and new `rolesanywhere` service for AWS provider to verify IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI [(#11319)](https://github.com/prowler-cloud/prowler/pull/11319)
- Sites, Additional Google services, and Marketplace checks for Google Workspace provider using the Cloud Identity Policy API [(#11281)](https://github.com/prowler-cloud/prowler/pull/11281)
- `entra_app_registration_client_secret_unused` check for M365 provider [(#11232)](https://github.com/prowler-cloud/prowler/pull/11232)
- `cloudsql_instance_cmek_encryption_enabled` check for GCP provider [(#11023)](https://github.com/prowler-cloud/prowler/pull/11023)
@@ -24,6 +32,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- OCI Audit service configuration lookup when the configured region differs from the tenancy home region [(#10347)](https://github.com/prowler-cloud/prowler/pull/10347)
- Container image now uses an absolute `ENTRYPOINT` (`/home/prowler/.venv/bin/prowler`) so it works under any runtime `--workdir`. The relative entrypoint was breaking the official GitHub Action (`prowler-cloud/prowler@v5.27.0`) and any `docker run` with a custom `-w` [(#11313)](https://github.com/prowler-cloud/prowler/pull/11313)
---
+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.1"
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 -15
View File
@@ -380,21 +380,6 @@ aws:
# Minimum number of Availability Zones that an ELBv2 must be in
elbv2_min_azs: 2
# AWS Post-Quantum TLS Configuration
# aws.acmpca_certificate_authority_pqc_key_algorithm
# Allowed post-quantum key algorithms for AWS Private CA certificate authorities
acmpca_pqc_key_algorithms:
- "ML_DSA_44"
- "ML_DSA_65"
- "ML_DSA_87"
# aws.rolesanywhere_trust_anchor_pqc_pki
# Allowed post-quantum key algorithms for AWS Private CAs backing IAM Roles Anywhere trust anchors
rolesanywhere_pqc_pca_key_algorithms:
- "ML_DSA_44"
- "ML_DSA_65"
- "ML_DSA_87"
# AWS Elasticache Configuration
# aws.elasticache_redis_cluster_backup_enabled
# Minimum number of days that a Redis cluster must have backups retention period
@@ -482,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,41 +0,0 @@
{
"Provider": "aws",
"CheckID": "acmpca_certificate_authority_pqc_key_algorithm",
"CheckTitle": "AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "acmpca",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "AwsAcmPcaCertificateAuthority",
"ResourceGroup": "security",
"Description": "**AWS Private Certificate Authorities (Private CAs)** are assessed for use of a **post-quantum digital signature key algorithm** (`ML_DSA_44`, `ML_DSA_65`, `ML_DSA_87`). CAs that still issue certificates with RSA or ECC algorithms produce signatures vulnerable to forgery once a cryptographically relevant quantum computer is available.",
"Risk": "RSA and ECC signatures can be broken by Shor's algorithm on a sufficiently large quantum computer. A compromised CA private key would let an attacker issue arbitrary certificates trusted across the PKI, undermining identity and code-signing controls. Migrating CAs to **ML-DSA** (NIST FIPS 204) provides quantum-resistant signatures so issued certificates retain integrity in the post-quantum era.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/privateca/latest/userguide/PcaTerms.html",
"https://aws.amazon.com/about-aws/whats-new/2025/11/aws-private-ca-post-quantum-digital-certificates/",
"https://aws.amazon.com/blogs/security/post-quantum-ml-dsa-code-signing-with-aws-private-ca-and-aws-kms/",
"https://csrc.nist.gov/pubs/fips/204/final"
],
"Remediation": {
"Code": {
"CLI": "aws acm-pca create-certificate-authority --certificate-authority-configuration '{\"KeyAlgorithm\":\"ML_DSA_65\",\"SigningAlgorithm\":\"ML_DSA_65\",\"Subject\":{...}}' --certificate-authority-type SUBORDINATE",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::ACMPCA::CertificateAuthority\n Properties:\n Type: SUBORDINATE\n KeyAlgorithm: ML_DSA_65 # FIX: post-quantum signature algorithm\n SigningAlgorithm: ML_DSA_65\n Subject:\n CommonName: example-pqc-ca\n```",
"Other": "Existing CAs cannot have their key algorithm changed; create a new CA with KeyAlgorithm = ML_DSA_44 / ML_DSA_65 / ML_DSA_87, re-issue certificates from it, and decommission the legacy CA once dependent workloads have rotated.",
"Terraform": "```hcl\nresource \"aws_acmpca_certificate_authority\" \"<example_resource_name>\" {\n type = \"SUBORDINATE\"\n certificate_authority_configuration {\n key_algorithm = \"ML_DSA_65\" # FIX: post-quantum signature algorithm\n signing_algorithm = \"ML_DSA_65\"\n subject {\n common_name = \"example-pqc-ca\"\n }\n }\n}\n```"
},
"Recommendation": {
"Text": "Create new Private CAs with a **post-quantum key algorithm** (`ML_DSA_44`, `ML_DSA_65`, or `ML_DSA_87`) and migrate workloads off legacy RSA/ECC CAs. Plan crypto-agility for your PKI so that quantum-resistant trust anchors can be rolled out before threat actors gain access to a cryptographically relevant quantum computer.",
"Url": "https://hub.prowler.com/check/acmpca_certificate_authority_pqc_key_algorithm"
}
},
"Categories": [
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -1,43 +0,0 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.acmpca.acmpca_client import acmpca_client
PQC_PCA_KEY_ALGORITHMS_DEFAULT = [
"ML_DSA_44",
"ML_DSA_65",
"ML_DSA_87",
]
class acmpca_certificate_authority_pqc_key_algorithm(Check):
"""Verify that every AWS Private CA uses a post-quantum key algorithm.
A Private CA PASSES when its ``KeyAlgorithm`` belongs to the configured
allowlist of post-quantum signature algorithms (ML-DSA family).
Deleted CAs are skipped.
"""
def execute(self) -> list[Check_Report_AWS]:
findings = []
pqc_algorithms = acmpca_client.audit_config.get(
"acmpca_pqc_key_algorithms", PQC_PCA_KEY_ALGORITHMS_DEFAULT
)
for ca in acmpca_client.certificate_authorities.values():
if ca.status == "DELETED":
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=ca)
algorithm = ca.key_algorithm or "<none>"
if ca.key_algorithm in pqc_algorithms:
report.status = "PASS"
report.status_extended = (
f"AWS Private CA {ca.id} uses post-quantum key algorithm "
f"{algorithm}."
)
else:
report.status = "FAIL"
report.status_extended = (
f"AWS Private CA {ca.id} uses key algorithm {algorithm}, "
"which is not post-quantum (ML-DSA)."
)
findings.append(report)
return findings
@@ -1,4 +0,0 @@
from prowler.providers.aws.services.acmpca.acmpca_service import ACMPCA
from prowler.providers.common.provider import Provider
acmpca_client = ACMPCA(Provider.get_global_provider())
@@ -1,57 +0,0 @@
from typing import Dict, List
from pydantic.v1 import BaseModel, Field
from prowler.lib.logger import logger
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
from prowler.providers.aws.lib.service.service import AWSService
class ACMPCA(AWSService):
def __init__(self, provider):
# The boto3 client identifier for AWS Private CA is "acm-pca"
super().__init__("acm-pca", provider)
self.certificate_authorities = {}
self.__threading_call__(self._list_certificate_authorities)
def _list_certificate_authorities(self, regional_client):
logger.info("ACM PCA - Listing Certificate Authorities...")
try:
paginator = regional_client.get_paginator("list_certificate_authorities")
for page in paginator.paginate():
for ca in page.get("CertificateAuthorities", []):
arn = ca.get("Arn", "")
if not arn:
continue
if self.audit_resources and not is_resource_filtered(
arn, self.audit_resources
):
continue
config = ca.get("CertificateAuthorityConfiguration", {})
self.certificate_authorities[arn] = CertificateAuthority(
arn=arn,
id=arn.split("/")[-1],
region=regional_client.region,
status=ca.get("Status", ""),
type=ca.get("Type", ""),
usage_mode=ca.get("UsageMode", ""),
key_algorithm=config.get("KeyAlgorithm", ""),
signing_algorithm=config.get("SigningAlgorithm", ""),
tags=[],
)
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class CertificateAuthority(BaseModel):
arn: str
id: str
region: str
status: str = ""
type: str = ""
usage_mode: str = ""
key_algorithm: str = ""
signing_algorithm: str = ""
tags: List[Dict[str, str]] = Field(default_factory=list)
@@ -1,6 +0,0 @@
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import (
RolesAnywhere,
)
from prowler.providers.common.provider import Provider
rolesanywhere_client = RolesAnywhere(Provider.get_global_provider())
@@ -1,55 +0,0 @@
from typing import Dict, List
from pydantic.v1 import BaseModel, Field
from prowler.lib.logger import logger
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
from prowler.providers.aws.lib.service.service import AWSService
class RolesAnywhere(AWSService):
def __init__(self, provider):
super().__init__(__class__.__name__, provider)
self.trust_anchors = {}
self.__threading_call__(self._list_trust_anchors)
def _list_trust_anchors(self, regional_client):
logger.info("RolesAnywhere - Listing Trust Anchors...")
try:
paginator = regional_client.get_paginator("list_trust_anchors")
for page in paginator.paginate():
for ta in page.get("trustAnchors", []):
arn = ta.get("trustAnchorArn", "")
if not arn:
continue
if self.audit_resources and not is_resource_filtered(
arn, self.audit_resources
):
continue
source = ta.get("source", {}) or {}
source_data = source.get("sourceData", {}) or {}
self.trust_anchors[arn] = TrustAnchor(
arn=arn,
id=ta.get("trustAnchorId", ""),
name=ta.get("name", ""),
region=regional_client.region,
enabled=ta.get("enabled", False),
source_type=source.get("sourceType", ""),
acm_pca_arn=source_data.get("acmPcaArn", ""),
tags=[],
)
except Exception as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class TrustAnchor(BaseModel):
arn: str
id: str
name: str
region: str
enabled: bool = False
source_type: str = ""
acm_pca_arn: str = ""
tags: List[Dict[str, str]] = Field(default_factory=list)
@@ -1,43 +0,0 @@
{
"Provider": "aws",
"CheckID": "rolesanywhere_trust_anchor_pqc_pki",
"CheckTitle": "IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices"
],
"ServiceName": "rolesanywhere",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "AwsRolesAnywhereTrustAnchor",
"ResourceGroup": "security",
"Description": "**IAM Roles Anywhere trust anchors** are assessed for use of a **post-quantum digital signature algorithm** (ML-DSA). A trust anchor backed by an AWS Private CA whose `KeyAlgorithm` is RSA or ECC produces signatures vulnerable to forgery by a future quantum attacker, allowing an unintended actor to issue certificates and obtain unauthorized AWS access.",
"Risk": "Trust anchors are the root of trust for workloads authenticating to AWS via X.509 certificates. If the signing CA uses RSA or ECC, an attacker with quantum capability could forge end-entity certificates and impersonate workloads. Migrating trust anchors to **ML-DSA-backed PKI** (NIST FIPS 204) protects this control plane in the post-quantum era.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html",
"https://aws.amazon.com/about-aws/whats-new/2026/03/iam-roles-anywhere-post-quantum-digital-certificates/",
"https://aws.amazon.com/security/post-quantum-cryptography/",
"https://csrc.nist.gov/pubs/fips/204/final"
],
"Remediation": {
"Code": {
"CLI": "aws rolesanywhere create-trust-anchor --name pqc-trust --source 'sourceType=AWS_ACM_PCA,sourceData={acmPcaArn=<ml_dsa_ca_arn>}' --enabled",
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::RolesAnywhere::TrustAnchor\n Properties:\n Name: pqc-trust\n Enabled: true\n Source:\n SourceType: AWS_ACM_PCA\n SourceData:\n AcmPcaArn: <ml_dsa_ca_arn> # FIX: PCA must use ML_DSA key algorithm\n```",
"Other": "1. Create a new AWS Private CA with a post-quantum KeyAlgorithm (ML_DSA_44/65/87)\n2. Create a new Roles Anywhere trust anchor with sourceType=AWS_ACM_PCA pointing to the new CA\n3. Rotate end-entity certificates issued from the new CA\n4. Delete the legacy trust anchor once workloads have rotated",
"Terraform": "```hcl\nresource \"aws_rolesanywhere_trust_anchor\" \"<example_resource_name>\" {\n name = \"pqc-trust\"\n enabled = true\n source {\n source_type = \"AWS_ACM_PCA\"\n source_data {\n acm_pca_arn = \"<ml_dsa_ca_arn>\" # FIX: PCA must use ML_DSA key algorithm\n }\n }\n}\n```"
},
"Recommendation": {
"Text": "Back IAM Roles Anywhere trust anchors with an **AWS Private CA that uses an ML-DSA key algorithm**. For trust anchors backed by an external certificate bundle, ensure the certificates were issued by an ML-DSA CA and re-verify periodically as the cryptographic landscape evolves.",
"Url": "https://hub.prowler.com/check/rolesanywhere_trust_anchor_pqc_pki"
}
},
"Categories": [
"encryption"
],
"DependsOn": [
"acmpca_certificate_authority_pqc_key_algorithm"
],
"RelatedTo": [],
"Notes": "Trust anchors backed by an external CERTIFICATE_BUNDLE cannot be evaluated automatically by this check and are reported as FAIL with guidance to migrate to an AWS Private CA using an ML-DSA key algorithm."
}
@@ -1,71 +0,0 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.acmpca.acmpca_client import acmpca_client
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import (
rolesanywhere_client,
)
PQC_PCA_KEY_ALGORITHMS_DEFAULT = [
"ML_DSA_44",
"ML_DSA_65",
"ML_DSA_87",
]
class rolesanywhere_trust_anchor_pqc_pki(Check):
"""Verify that IAM Roles Anywhere trust anchors are backed by a post-quantum PKI.
For trust anchors whose source is ``AWS_ACM_PCA``, the linked Private CA's
``KeyAlgorithm`` is checked against the configured ML-DSA allowlist.
Trust anchors backed by an external ``CERTIFICATE_BUNDLE`` are reported as
FAIL because their certificate signature algorithm cannot be inspected
from the IAM Roles Anywhere API alone.
"""
def execute(self) -> list[Check_Report_AWS]:
findings = []
pqc_algorithms = rolesanywhere_client.audit_config.get(
"rolesanywhere_pqc_pca_key_algorithms", PQC_PCA_KEY_ALGORITHMS_DEFAULT
)
for trust_anchor in rolesanywhere_client.trust_anchors.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=trust_anchor)
if trust_anchor.source_type == "AWS_ACM_PCA":
linked_ca = acmpca_client.certificate_authorities.get(
trust_anchor.acm_pca_arn
)
if linked_ca and linked_ca.key_algorithm in pqc_algorithms:
report.status = "PASS"
report.status_extended = (
f"IAM Roles Anywhere trust anchor {trust_anchor.name} is "
f"backed by Private CA {linked_ca.id} using post-quantum "
f"key algorithm {linked_ca.key_algorithm}."
)
elif linked_ca:
report.status = "FAIL"
report.status_extended = (
f"IAM Roles Anywhere trust anchor {trust_anchor.name} is "
f"backed by Private CA {linked_ca.id} using key algorithm "
f"{linked_ca.key_algorithm or '<unknown>'}, which is not "
"post-quantum (ML-DSA)."
)
else:
report.status = "FAIL"
report.status_extended = (
f"IAM Roles Anywhere trust anchor {trust_anchor.name} is "
f"backed by Private CA {trust_anchor.acm_pca_arn}, which "
"could not be inspected (cross-account or missing "
"acm-pca permissions). Verify the CA uses an ML-DSA key "
"algorithm."
)
else:
source = trust_anchor.source_type or "<none>"
report.status = "FAIL"
report.status_extended = (
f"IAM Roles Anywhere trust anchor {trust_anchor.name} uses "
f"source type {source}; the certificate signature algorithm "
"cannot be inspected automatically. Migrate to an AWS Private "
"CA using an ML-DSA key algorithm to enable post-quantum "
"evaluation."
)
findings.append(report)
return findings
+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.1"
[project.scripts]
prowler = "prowler.__main__:prowler"
@@ -1,157 +0,0 @@
from unittest import mock
from prowler.providers.aws.services.acmpca.acmpca_service import CertificateAuthority
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
CA_ID = "12345678-1234-1234-1234-123456789012"
CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}"
def _build_client(certificate_authorities, audit_config=None):
acmpca_client = mock.MagicMock()
acmpca_client.certificate_authorities = certificate_authorities
acmpca_client.audit_config = audit_config or {}
return acmpca_client
def _ca(key_algorithm: str, status: str = "ACTIVE"):
return CertificateAuthority(
arn=CA_ARN,
id=CA_ID,
region=AWS_REGION_US_EAST_1,
status=status,
type="SUBORDINATE",
usage_mode="GENERAL_PURPOSE",
key_algorithm=key_algorithm,
signing_algorithm="ML_DSA_65" if "ML_DSA" in key_algorithm else "SHA256WITHRSA",
)
class Test_acmpca_certificate_authority_pqc_key_algorithm:
def test_no_cas(self):
acmpca_client = _build_client({})
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client",
new=acmpca_client,
),
):
from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import (
acmpca_certificate_authority_pqc_key_algorithm,
)
check = acmpca_certificate_authority_pqc_key_algorithm()
result = check.execute()
assert len(result) == 0
def test_ml_dsa_65(self):
acmpca_client = _build_client({CA_ARN: _ca("ML_DSA_65")})
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client",
new=acmpca_client,
),
):
from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import (
acmpca_certificate_authority_pqc_key_algorithm,
)
check = acmpca_certificate_authority_pqc_key_algorithm()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "ML_DSA_65" in result[0].status_extended
assert result[0].resource_id == CA_ID
assert result[0].resource_arn == CA_ARN
def test_rsa_2048_fails(self):
acmpca_client = _build_client({CA_ARN: _ca("RSA_2048")})
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client",
new=acmpca_client,
),
):
from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import (
acmpca_certificate_authority_pqc_key_algorithm,
)
check = acmpca_certificate_authority_pqc_key_algorithm()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "RSA_2048" in result[0].status_extended
def test_deleted_ca_skipped(self):
acmpca_client = _build_client({CA_ARN: _ca("RSA_2048", status="DELETED")})
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client",
new=acmpca_client,
),
):
from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import (
acmpca_certificate_authority_pqc_key_algorithm,
)
check = acmpca_certificate_authority_pqc_key_algorithm()
result = check.execute()
assert len(result) == 0
def test_configurable_allowlist(self):
acmpca_client = _build_client(
{CA_ARN: _ca("RSA_2048")},
audit_config={"acmpca_pqc_key_algorithms": ["ML_DSA_65", "RSA_2048"]},
)
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client",
new=acmpca_client,
),
):
from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import (
acmpca_certificate_authority_pqc_key_algorithm,
)
check = acmpca_certificate_authority_pqc_key_algorithm()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
@@ -1,61 +0,0 @@
from unittest.mock import patch
import botocore
from moto import mock_aws
from prowler.providers.aws.services.acmpca.acmpca_service import (
ACMPCA,
CertificateAuthority,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
CA_ID = "12345678-1234-1234-1234-123456789012"
CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}"
make_api_call = botocore.client.BaseClient._make_api_call
def mock_make_api_call(self, operation_name, kwarg):
if operation_name == "ListCertificateAuthorities":
return {
"CertificateAuthorities": [
{
"Arn": CA_ARN,
"Status": "ACTIVE",
"Type": "SUBORDINATE",
"UsageMode": "GENERAL_PURPOSE",
"CertificateAuthorityConfiguration": {
"KeyAlgorithm": "ML_DSA_65",
"SigningAlgorithm": "ML_DSA_65",
},
}
]
}
return make_api_call(self, operation_name, kwarg)
class Test_ACMPCA_Service:
@mock_aws
def test_service(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
acmpca = ACMPCA(aws_provider)
assert acmpca.service == "acm-pca"
@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
def test_list_certificate_authorities(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
acmpca = ACMPCA(aws_provider)
assert len(acmpca.certificate_authorities) == 1
ca = acmpca.certificate_authorities[CA_ARN]
assert isinstance(ca, CertificateAuthority)
assert ca.id == CA_ID
assert ca.region == AWS_REGION_US_EAST_1
assert ca.status == "ACTIVE"
assert ca.type == "SUBORDINATE"
assert ca.key_algorithm == "ML_DSA_65"
assert ca.signing_algorithm == "ML_DSA_65"
@@ -1,62 +0,0 @@
from unittest.mock import patch
import botocore
from moto import mock_aws
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import (
RolesAnywhere,
TrustAnchor,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
TA_ID = "11111111-2222-3333-4444-555555555555"
TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}"
PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/abc"
make_api_call = botocore.client.BaseClient._make_api_call
def mock_make_api_call(self, operation_name, kwarg):
if operation_name == "ListTrustAnchors":
return {
"trustAnchors": [
{
"trustAnchorArn": TA_ARN,
"trustAnchorId": TA_ID,
"name": "pqc-trust",
"enabled": True,
"source": {
"sourceType": "AWS_ACM_PCA",
"sourceData": {"acmPcaArn": PCA_ARN},
},
}
]
}
return make_api_call(self, operation_name, kwarg)
class Test_RolesAnywhere_Service:
@mock_aws
def test_service(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
rolesanywhere = RolesAnywhere(aws_provider)
assert rolesanywhere.service == "rolesanywhere"
@patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call)
@mock_aws
def test_list_trust_anchors(self):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
rolesanywhere = RolesAnywhere(aws_provider)
assert len(rolesanywhere.trust_anchors) == 1
ta = rolesanywhere.trust_anchors[TA_ARN]
assert isinstance(ta, TrustAnchor)
assert ta.id == TA_ID
assert ta.name == "pqc-trust"
assert ta.enabled is True
assert ta.source_type == "AWS_ACM_PCA"
assert ta.acm_pca_arn == PCA_ARN
assert ta.region == AWS_REGION_US_EAST_1
@@ -1,153 +0,0 @@
from unittest import mock
from prowler.providers.aws.services.acmpca.acmpca_service import CertificateAuthority
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import (
TrustAnchor,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
AWS_REGION_US_EAST_1,
set_mocked_aws_provider,
)
TA_ID = "11111111-2222-3333-4444-555555555555"
TA_NAME = "pqc-trust"
TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}"
PCA_ID = "12345678-1234-1234-1234-123456789012"
PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{PCA_ID}"
def _trust_anchor(*, source_type: str, acm_pca_arn: str = ""):
return TrustAnchor(
arn=TA_ARN,
id=TA_ID,
name=TA_NAME,
region=AWS_REGION_US_EAST_1,
enabled=True,
source_type=source_type,
acm_pca_arn=acm_pca_arn,
)
def _ca(key_algorithm: str):
return CertificateAuthority(
arn=PCA_ARN,
id=PCA_ID,
region=AWS_REGION_US_EAST_1,
status="ACTIVE",
type="SUBORDINATE",
usage_mode="GENERAL_PURPOSE",
key_algorithm=key_algorithm,
signing_algorithm=(
key_algorithm if "ML_DSA" in key_algorithm else "SHA256WITHRSA"
),
)
def _build_clients(trust_anchors, certificate_authorities=None, audit_config=None):
ra_client = mock.MagicMock()
ra_client.trust_anchors = trust_anchors
ra_client.audit_config = audit_config or {}
pca_client = mock.MagicMock()
pca_client.certificate_authorities = certificate_authorities or {}
return ra_client, pca_client
def _patched(ra_client, pca_client):
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
return [
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_client",
new=ra_client,
),
mock.patch(
"prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki.acmpca_client",
new=pca_client,
),
]
def _enter(patches):
from contextlib import ExitStack
stack = ExitStack()
for p in patches:
stack.enter_context(p)
return stack
class Test_rolesanywhere_trust_anchor_pqc_pki:
def test_no_trust_anchors(self):
ra_client, pca_client = _build_clients({})
with _enter(_patched(ra_client, pca_client)):
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import (
rolesanywhere_trust_anchor_pqc_pki,
)
result = rolesanywhere_trust_anchor_pqc_pki().execute()
assert len(result) == 0
def test_pca_backed_pqc(self):
ra_client, pca_client = _build_clients(
{TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)},
certificate_authorities={PCA_ARN: _ca("ML_DSA_65")},
)
with _enter(_patched(ra_client, pca_client)):
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import (
rolesanywhere_trust_anchor_pqc_pki,
)
result = rolesanywhere_trust_anchor_pqc_pki().execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "ML_DSA_65" in result[0].status_extended
assert result[0].resource_id == TA_ID
assert result[0].resource_arn == TA_ARN
def test_pca_backed_rsa(self):
ra_client, pca_client = _build_clients(
{TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)},
certificate_authorities={PCA_ARN: _ca("RSA_2048")},
)
with _enter(_patched(ra_client, pca_client)):
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import (
rolesanywhere_trust_anchor_pqc_pki,
)
result = rolesanywhere_trust_anchor_pqc_pki().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "RSA_2048" in result[0].status_extended
def test_pca_not_in_inventory(self):
ra_client, pca_client = _build_clients(
{TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)},
certificate_authorities={},
)
with _enter(_patched(ra_client, pca_client)):
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import (
rolesanywhere_trust_anchor_pqc_pki,
)
result = rolesanywhere_trust_anchor_pqc_pki().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "could not be inspected" in result[0].status_extended
def test_certificate_bundle_source(self):
ra_client, pca_client = _build_clients(
{TA_ARN: _trust_anchor(source_type="CERTIFICATE_BUNDLE")},
)
with _enter(_patched(ra_client, pca_client)):
from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import (
rolesanywhere_trust_anchor_pqc_pki,
)
result = rolesanywhere_trust_anchor_pqc_pki().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "CERTIFICATE_BUNDLE" in result[0].status_extended
+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()
+15 -2
View File
@@ -2,12 +2,25 @@
All notable changes to the **Prowler UI** are documented in this file.
## [1.28.0] (Prowler UNRELEASED)
## [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
- `okta` provider support with OAuth 2.0 private-key JWT credentials form (client ID + PEM private key) [(#11213)](https://github.com/prowler-cloud/prowler/pull/11213)
- "Resource Metadata / Evidence" tab in the finding detail drawer—reachable from the compliance requirement findings view, the Findings page, and the Resources view—rendering the affected resource's details and metadata as syntax-highlighted JSON with copy-to-clipboard, via a shared `ResourceMetadataPanel` also reused by the resource detail view [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187)
- "Resource Metadata / Evidence" tab in the finding detail drawer [(#11187)](https://github.com/prowler-cloud/prowler/pull/11187)
### 🐞 Fixed
- Resource detail panels: metadata editor now scrolls internally with the minimal scrollbar across the finding drawer and `/resources/:id`, tab labels truncate with tooltips on narrow widths, and "View in AWS Console" moved from the resource UID row to the resource actions menu [(#11325)](https://github.com/prowler-cloud/prowler/pull/11325)
---
@@ -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,
@@ -91,12 +91,14 @@ vi.mock("@/components/shadcn", () => {
InfoField: ({
children,
label,
className,
}: {
children: ReactNode;
label: string;
variant?: string;
className?: string;
}) => (
<div>
<div className={className}>
<span>{label}</span>
{children}
</div>
@@ -280,12 +282,6 @@ vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
),
}));
vi.mock("@/components/ui/custom/custom-link", () => ({
CustomLink: ({ children, href }: { children: ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
vi.mock("@/components/ui/entities/date-with-time", () => ({
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
}));
@@ -784,12 +780,19 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => {
);
expect(screen.getByText(statusExtendedWithFixVersions)).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "View in Prowler Hub" }),
).toHaveAttribute(
const hubLink = screen.getByRole("link", { name: "View in Prowler Hub" });
expect(hubLink).toHaveAttribute(
"href",
"https://hub.prowler.com/check/image_vulnerability",
);
expect(hubLink).toHaveAttribute("target", "_blank");
expect(hubLink).toHaveAttribute("rel", "noopener noreferrer");
const headingRow = screen.getByTestId("remediation-heading-row");
expect(within(headingRow).getByText("Remediation:")).toBeInTheDocument();
expect(hubLink).toHaveClass("shrink-0", "whitespace-nowrap");
expect(
within(headingRow).queryByText("Open the check in Hub"),
).not.toBeInTheDocument();
});
it("should render the official CVE reference", () => {
@@ -836,10 +839,12 @@ describe("ResourceDetailDrawerContent — CVE recommendation button", () => {
"href",
externalCveUrl,
);
expect(screen.getByRole("link", { name: externalCveUrl })).toHaveAttribute(
"href",
externalCveUrl,
);
const referenceLink = screen.getByRole("link", { name: externalCveUrl });
expect(referenceLink).toHaveAttribute("href", externalCveUrl);
expect(referenceLink).toHaveAttribute("target", "_blank");
expect(referenceLink).toHaveAttribute("rel", "noopener noreferrer");
expect(referenceLink).toHaveClass("break-all", "text-left");
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
it("should render View Advisory when the recommendation URL points to GitHub Security Advisories", () => {
@@ -1345,6 +1350,64 @@ describe("ResourceDetailDrawerContent — synthetic resource empty state", () =>
});
describe("ResourceDetailDrawerContent — current resource row display", () => {
it("should place service and region in the primary metadata row after provider and resource", () => {
// Given/When
render(
<ResourceDetailDrawerContent
isLoading={false}
isNavigating={false}
checkMeta={mockCheckMeta}
currentIndex={0}
totalResources={1}
currentFinding={mockFinding}
otherFindings={[]}
onNavigatePrev={vi.fn()}
onNavigateNext={vi.fn()}
onMuteComplete={vi.fn()}
/>,
);
// Then
const primaryMetadataRow = screen.getByTestId(
"resource-detail-primary-metadata-row",
);
expect(primaryMetadataRow).toHaveClass("grid-cols-2");
expect(primaryMetadataRow).toHaveClass(
"@md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)]",
);
expect(
within(primaryMetadataRow).getByText("Provider"),
).toBeInTheDocument();
expect(
within(primaryMetadataRow).getByText("Resource"),
).toBeInTheDocument();
expect(within(primaryMetadataRow).getByText("Service")).toBeInTheDocument();
expect(within(primaryMetadataRow).getByText("Region")).toBeInTheDocument();
expect(within(primaryMetadataRow).getByText("s3")).toHaveClass(
"truncate",
"whitespace-nowrap",
);
expect(within(primaryMetadataRow).getByText("us-east-1")).toHaveClass(
"truncate",
);
const secondaryMetadataRow = screen.getByTestId(
"resource-detail-secondary-metadata-row",
);
expect(secondaryMetadataRow).toHaveClass("grid-cols-2");
expect(secondaryMetadataRow).toHaveClass("@md:grid-cols-3");
expect(
within(secondaryMetadataRow).queryByText("Service"),
).not.toBeInTheDocument();
expect(
within(secondaryMetadataRow).queryByText("Region"),
).not.toBeInTheDocument();
expect(within(secondaryMetadataRow).getByText("2 days")).toHaveClass(
"truncate",
"whitespace-nowrap",
);
});
it("should render resource card fields from the current resource row instead of the fetched finding", () => {
// Given
const currentResource: FindingResourceRow = {
@@ -1481,10 +1544,10 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
expect(screen.getByText("ec2")).toBeInTheDocument();
expect(screen.getByText("eu-west-1")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Finding Overview" }),
screen.getByRole("button", { name: "Overview" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Findings for this resource" }),
screen.getByRole("button", { name: "Other findings" }),
).toBeInTheDocument();
expect(screen.queryByText("uid-1")).not.toBeInTheDocument();
expect(screen.queryByText("Status extended")).not.toBeInTheDocument();
@@ -1594,10 +1657,10 @@ describe("ResourceDetailDrawerContent — header skeleton while navigating", ()
expect(screen.queryByText("Description:")).not.toBeInTheDocument();
expect(screen.queryByText("Remediation:")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Finding Overview" }),
screen.getByRole("button", { name: "Overview" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Findings for this resource" }),
screen.getByRole("button", { name: "Other findings" }),
).toBeInTheDocument();
});
@@ -1796,7 +1859,7 @@ describe("ResourceDetailDrawerContent — Metadata tab", () => {
// Then
expect(
screen.getByRole("button", { name: "Resource Metadata / Evidence" }),
screen.getByRole("button", { name: "Evidence" }),
).toBeInTheDocument();
});
@@ -44,10 +44,7 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline";
import {
ExternalResourceLink,
resolveExternalTarget,
} from "@/components/shared/external-resource-link";
import { resolveExternalTarget } from "@/components/shared/external-resource-link";
import {
QUERY_EDITOR_LANGUAGE,
QueryCodeEditor,
@@ -55,7 +52,6 @@ import {
} from "@/components/shared/query-code-editor";
import { ResourceMetadataPanel } from "@/components/shared/resource-metadata-panel";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import {
@@ -442,7 +438,6 @@ export function ResourceDetailDrawerContent({
findingUid: f?.uid,
region: resourceRegion,
});
const hasIdAction = Boolean(externalResourceTarget);
const findingRecommendationUrl = f?.remediation.recommendation.url;
const checkRecommendationUrl = checkMeta.remediation.recommendation.url;
const recommendationUrl = isNonEmptyString(findingRecommendationUrl)
@@ -690,9 +685,12 @@ export function ResourceDetailDrawerContent({
<div className="flex items-start gap-4">
{/* Resource info grid — 4 data columns */}
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
{/* Row 1: Provider (cols 1-2), Resource (cols 3-5) */}
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
<div className="flex min-w-0 flex-col gap-1 @md:col-span-2">
{/* Row 1: Provider, Resource, Service, Region */}
<div
className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)] @md:gap-x-8"
data-testid="resource-detail-primary-metadata-row"
>
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
Provider
</span>
@@ -703,7 +701,7 @@ export function ResourceDetailDrawerContent({
entityId={providerUid}
/>
</div>
<div className="flex min-w-0 flex-col gap-1 @md:col-span-3">
<div className="col-span-2 flex min-w-0 flex-col gap-1 @md:col-span-1">
<span className="text-text-neutral-secondary text-[10px] whitespace-nowrap">
Resource
</span>
@@ -737,44 +735,59 @@ export function ResourceDetailDrawerContent({
</Tooltip>
) : undefined
}
idAction={
hasIdAction ? (
<ExternalResourceLink
providerType={providerType}
resourceUid={resourceUid}
providerUid={providerUid}
resourceName={resourceName}
findingUid={f?.uid}
region={resourceRegion}
/>
) : undefined
}
/>
</div>
</div>
{/* Row 2: Last detected, First seen, Failing for, Service, Region */}
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
<InfoField label="Last detected" variant="compact">
<DateWithTime inline dateTime={lastSeenAt || "-"} />
<InfoField
label="Service"
variant="compact"
className="min-w-0"
>
<span className="block truncate whitespace-nowrap">
{resourceService}
</span>
</InfoField>
<InfoField label="First seen" variant="compact">
<DateWithTime inline dateTime={firstSeenAt || "-"} />
</InfoField>
<InfoField label="Failing for" variant="compact">
{getFailingForLabel(firstSeenAt) || "-"}
</InfoField>
<InfoField label="Service" variant="compact">
{resourceService}
</InfoField>
<InfoField label="Region" variant="compact">
<span className="flex items-center gap-1.5">
<InfoField
label="Region"
variant="compact"
className="min-w-0"
>
<span className="flex min-w-0 items-center gap-1.5 whitespace-nowrap">
{getRegionFlag(resourceRegionLabel) && (
<span className="translate-y-px text-base leading-none">
<span className="shrink-0 translate-y-px text-base leading-none">
{getRegionFlag(resourceRegionLabel)}
</span>
)}
{resourceRegionLabel}
<span className="truncate">{resourceRegionLabel}</span>
</span>
</InfoField>
</div>
{/* Row 2: Last detected, First seen, Failing for */}
<div
className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-3 @md:gap-x-8"
data-testid="resource-detail-secondary-metadata-row"
>
<InfoField
label="Last detected"
variant="compact"
className="min-w-0"
>
<DateWithTime inline dateTime={lastSeenAt || "-"} />
</InfoField>
<InfoField
label="First seen"
variant="compact"
className="min-w-0"
>
<DateWithTime inline dateTime={firstSeenAt || "-"} />
</InfoField>
<InfoField
label="Failing for"
variant="compact"
className="min-w-0"
>
<span className="block truncate whitespace-nowrap">
{getFailingForLabel(firstSeenAt) || "-"}
</span>
</InfoField>
</div>
@@ -804,6 +817,19 @@ export function ResourceDetailDrawerContent({
label="Send to Jira"
onSelect={() => setIsJiraModalOpen(true)}
/>
{externalResourceTarget && (
<ActionDropdownItem
icon={<ExternalLink className="size-5" />}
label={externalResourceTarget.label}
onSelect={() =>
window.open(
externalResourceTarget.url,
"_blank",
"noopener,noreferrer",
)
}
/>
)}
</ActionDropdown>
) : (
<Skeleton className="size-8 rounded-md" />
@@ -844,20 +870,31 @@ export function ResourceDetailDrawerContent({
>
<div className="mb-4 flex items-center justify-between">
<TabsList>
<TabsTrigger value="overview">Finding Overview</TabsTrigger>
<TabsTrigger value="remediation">Remediation</TabsTrigger>
<TabsTrigger value="metadata">
Resource Metadata / Evidence
<TabsTrigger value="overview" tooltip="Overview">
Overview
</TabsTrigger>
<TabsTrigger value="other-findings">
Findings for this resource
<TabsTrigger value="remediation" tooltip="Remediation">
Remediation
</TabsTrigger>
<TabsTrigger value="metadata" tooltip="Resource Metadata">
Evidence
</TabsTrigger>
<TabsTrigger
value="other-findings"
tooltip="Other Findings for this resource"
>
Other findings
</TabsTrigger>
<TabsTrigger value="scans" tooltip="Scans">
Scans
</TabsTrigger>
<TabsTrigger value="events" tooltip="Events">
Events
</TabsTrigger>
<TabsTrigger value="scans">Scans</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
</TabsList>
</div>
{/* Finding Overview — check-level data from checkMeta (always stable) */}
{/* Overview — check-level data from checkMeta (always stable) */}
<TabsContent
value="overview"
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
@@ -892,19 +929,26 @@ export function ResourceDetailDrawerContent({
<span className="text-text-neutral-secondary text-xs">
References:
</span>
<ul className="list-inside list-disc space-y-1">
{checkMeta.additionalUrls.map((link, idx) => (
<li key={idx}>
<CustomLink
<div className="flex flex-col items-start gap-1">
{checkMeta.additionalUrls.map((link) => (
<Button
key={link}
variant="link"
size="link-xs"
className="h-auto justify-start p-0 text-left break-all whitespace-normal!"
asChild
>
<Link
href={link}
size="sm"
className="break-all whitespace-normal!"
target="_blank"
rel="noopener noreferrer"
prefetch={false}
>
{link}
</CustomLink>
</li>
</Link>
</Button>
))}
</ul>
</div>
</div>
</Card>
)}
@@ -986,26 +1030,39 @@ export function ResourceDetailDrawerContent({
{(checkMeta.remediation.recommendation.text ||
recommendationLink) && (
<div className="flex flex-col gap-1 px-1">
<span className="text-text-neutral-primary text-sm font-semibold">
Remediation:
</span>
<div className="flex items-start gap-3">
<div
className="flex min-w-0 items-center justify-between gap-3"
data-testid="remediation-heading-row"
>
<span className="text-text-neutral-primary text-sm font-semibold">
Remediation:
</span>
{recommendationLink && (
<Button
variant="link"
size="link-xs"
className="shrink-0 whitespace-nowrap"
asChild
>
<Link
href={recommendationLink.href}
target="_blank"
rel="noopener noreferrer"
prefetch={false}
>
{recommendationLink.label}
</Link>
</Button>
)}
</div>
<div>
{checkMeta.remediation.recommendation.text && (
<div className="text-text-neutral-primary flex-1 text-sm">
<div className="text-text-neutral-primary text-sm">
<MarkdownContainer>
{checkMeta.remediation.recommendation.text}
</MarkdownContainer>
</div>
)}
{recommendationLink && (
<CustomLink
href={recommendationLink.href}
size="sm"
className="shrink-0"
>
{recommendationLink.label}
</CustomLink>
)}
</div>
</div>
)}
@@ -1079,7 +1136,7 @@ export function ResourceDetailDrawerContent({
{/* Metadata */}
<TabsContent
value="metadata"
className="minimal-scrollbar flex flex-col gap-4 overflow-y-auto"
className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden"
>
{isNavigating ? (
<MetadataNavigationSkeleton />
@@ -1091,7 +1148,7 @@ export function ResourceDetailDrawerContent({
)}
</TabsContent>
{/* Findings for this resource */}
{/* Other findings — findings affecting this same resource */}
<TabsContent
value="other-findings"
className="minimal-scrollbar flex flex-col gap-2 overflow-y-auto"
@@ -9,19 +9,23 @@ export function ResourceDetailSkeleton() {
return (
<div className="flex items-start gap-4">
<div className="@container flex min-w-0 flex-1 flex-col gap-4">
{/* Row 1: Provider, Resource */}
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] @md:gap-x-8">
<EntityInfoSkeleton hasIcon labelWidth="w-12" />
<EntityInfoSkeleton labelWidth="w-14" />
{/* Row 1: Provider, Resource, Service, Region */}
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,0.55fr)_minmax(0,0.7fr)] @md:gap-x-8">
<div className="col-span-2 @md:col-span-1">
<EntityInfoSkeleton hasIcon labelWidth="w-12" />
</div>
<div className="col-span-2 @md:col-span-1">
<EntityInfoSkeleton labelWidth="w-14" />
</div>
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
</div>
{/* Row 2: Last detected, First seen, Failing for, Service, Region */}
<div className="grid min-w-0 grid-cols-1 gap-4 @md:grid-cols-5 @md:gap-x-8">
{/* Row 2: Last detected, First seen, Failing for */}
<div className="grid min-w-0 grid-cols-2 gap-4 @md:grid-cols-3 @md:gap-x-8">
<InfoFieldSkeleton labelWidth="w-20" valueWidth="w-32" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-32" />
<InfoFieldSkeleton labelWidth="w-16" valueWidth="w-16" />
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-20" />
<InfoFieldSkeleton labelWidth="w-12" valueWidth="w-24" />
</div>
</div>
@@ -26,4 +26,25 @@ describe("resource detail content", () => {
expect(source).not.toContain("useEffect");
expect(source).not.toContain("useEffect(");
});
it("renders the external resource link below the resource title row", () => {
expect(source).toContain(`</div>
<ExternalResourceLink`);
expect(source).toMatch(
/className="(?:self-start justify-start|justify-start self-start)"/,
);
});
it("keeps resource date fields together on the third details row", () => {
expect(source).toContain(
'className="grid min-w-0 grid-cols-2 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4"',
);
expect(source).toContain('className="col-span-2 md:col-span-1"');
expect(source).toContain(`label="Created At"
variant="compact"
className="col-start-1 min-w-0"`);
expect(source).toContain(`label="Last Updated"
variant="compact"
className="col-start-2 min-w-0"`);
});
});
@@ -228,19 +228,20 @@ export const ResourceDetailContent = ({
</TooltipTrigger>
<TooltipContent>Copy resource link to clipboard</TooltipContent>
</Tooltip>
<ExternalResourceLink
providerType={providerData.provider}
resourceUid={attributes.uid}
providerUid={providerData.uid}
resourceName={attributes.name}
region={attributes.region}
/>
</div>
<ExternalResourceLink
providerType={providerData.provider}
resourceUid={attributes.uid}
providerUid={providerData.uid}
resourceName={attributes.name}
region={attributes.region}
className="justify-start self-start"
/>
</div>
</div>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex min-h-0 flex-1 flex-col gap-4 overflow-hidden rounded-lg border p-4">
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
<div className="grid min-w-0 grid-cols-2 gap-4 md:grid-cols-4 md:gap-x-8 md:gap-y-4">
{providerOrg ? (
<div className="col-span-2 flex flex-col gap-1">
<EntityInfo
@@ -258,13 +259,21 @@ export const ResourceDetailContent = ({
</div>
</div>
) : (
<EntityInfo
cloudProvider={providerData.provider as ProviderType}
entityAlias={providerData.alias ?? undefined}
entityId={providerData.uid}
/>
<div className="col-span-2 md:col-span-1">
<EntityInfo
cloudProvider={providerData.provider as ProviderType}
entityAlias={providerData.alias ?? undefined}
entityId={providerData.uid}
/>
</div>
)}
<div className={providerOrg ? "self-end" : undefined}>
<div
className={
providerOrg
? "col-span-2 self-end md:col-span-1"
: "col-span-2 md:col-span-1"
}
>
<EntityInfo
nameIcon={<Container className="size-4" />}
entityAlias={resourceName}
@@ -299,10 +308,18 @@ export const ResourceDetailContent = ({
{renderValue(attributes.partition)}
</InfoField>
<InfoField label="Created At" variant="compact">
<InfoField
label="Created At"
variant="compact"
className="col-start-1 min-w-0"
>
<DateWithTime inline dateTime={attributes.inserted_at || "-"} />
</InfoField>
<InfoField label="Last Updated" variant="compact">
<InfoField
label="Last Updated"
variant="compact"
className="col-start-2 min-w-0"
>
<DateWithTime inline dateTime={attributes.updated_at || "-"} />
</InfoField>
</div>
@@ -320,9 +337,15 @@ export const ResourceDetailContent = ({
<InfoTooltip content="This table also includes muted findings" />
</span>
</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
<TabsTrigger value="tags">Tags</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
<TabsTrigger value="metadata" tooltip="Resource Metadata">
Evidence
</TabsTrigger>
<TabsTrigger value="tags" tooltip="Tags">
Tags
</TabsTrigger>
<TabsTrigger value="events" tooltip="Events">
Events
</TabsTrigger>
</TabsList>
</div>
@@ -372,7 +395,10 @@ export const ResourceDetailContent = ({
)}
</TabsContent>
<TabsContent value="metadata" className="flex flex-col gap-4">
<TabsContent
value="metadata"
className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden"
>
<ResourceMetadataPanel
metadata={attributes.metadata}
details={attributes.details}
@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
describe("shadcn Button", () => {
it("supports extra-small link buttons", () => {
render(
<Button variant="link" size="link-xs">
Open link
</Button>,
);
expect(screen.getByRole("button", { name: "Open link" })).toHaveClass(
"text-xs",
);
});
});
+1
View File
@@ -37,6 +37,7 @@ const buttonVariants = cva(
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
"link-xs": "text-xs",
"link-sm": "text-sm",
},
},
+38 -6
View File
@@ -1,15 +1,20 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type { ComponentProps } from "react";
import type { ComponentProps, ReactNode } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
/**
* Trigger component style parts using semantic class names
*/
const TRIGGER_STYLES = {
base: "relative inline-flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4",
base: "relative inline-flex min-w-0 items-center justify-center gap-2 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&:not(:first-child)]:pl-4 [&:not(:last-child)]:pr-4",
border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]",
text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white",
active:
@@ -46,7 +51,10 @@ function buildTriggerClassName(): string {
* Build list className
*/
function buildListClassName(): string {
return "inline-flex w-full items-center border-[#E9E9F0] dark:border-[#171D30]";
// `flex` + `min-w-0` lets the triggers shrink proportionally when the
// container is narrow, so each trigger truncates with ellipsis instead
// of forcing a horizontal scrollbar.
return "flex w-full min-w-0 items-center border-[#E9E9F0] dark:border-[#171D30]";
}
function Tabs({
@@ -75,16 +83,40 @@ function TabsList({
);
}
interface TabsTriggerProps
extends ComponentProps<typeof TabsPrimitive.Trigger> {
/**
* When set, the trigger is wrapped in a shadcn Tooltip rendered below
* the bar. Useful for showing the full name when the label is truncated
* to ellipsis on narrow containers.
*/
tooltip?: ReactNode;
}
function TabsTrigger({
className,
tooltip,
children,
...props
}: ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
}: TabsTriggerProps) {
const trigger = (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(buildTriggerClassName(), className)}
{...props}
/>
>
{/* Wrapper provides the block-level box needed for `truncate` to
* actually render an ellipsis. Padding and gap on the trigger stay
* constant; only this span shrinks below its content width. */}
<span className="block min-w-0 truncate">{children}</span>
</TabsPrimitive.Trigger>
);
if (!tooltip) return trigger;
return (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="bottom">{tooltip}</TooltipContent>
</Tooltip>
);
}
@@ -18,6 +18,7 @@ describe("ExternalResourceLink", () => {
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
expect(link).toHaveTextContent("View in AWS Console");
expect(link).toHaveClass("text-xs");
});
it("renders a repository link for IaC resources", () => {
@@ -73,7 +73,7 @@ export const ExternalResourceLink = (props: ExternalResourceLinkProps) => {
<TooltipTrigger asChild>
<Button
variant="link"
size="link-sm"
size="link-xs"
asChild
className={props.className}
>
+19 -3
View File
@@ -1103,9 +1103,11 @@ const DARK_SELECTION_BG = "rgba(121, 192, 255, 0.18)";
function createEditorTheme({
isDarkMode,
minHeight,
fill,
}: {
isDarkMode: boolean;
minHeight: number;
fill?: boolean;
}) {
return EditorView.theme(
{
@@ -1114,12 +1116,17 @@ function createEditorTheme({
color: "var(--text-neutral-primary)",
fontFamily: MONO_FONT,
fontSize: "12px",
// When filling, the editor takes the full height of its (bounded)
// wrapper so the scroller below can scroll instead of growing.
...(fill && { height: "100%" }),
},
"&.cm-focused": {
outline: "none",
},
".cm-scroller": {
minHeight: `${minHeight}px`,
// A fixed min-height would force the editor to overflow a smaller
// container; when filling we let flexbox size it instead.
...(fill ? {} : { minHeight: `${minHeight}px` }),
overflow: "auto",
fontFamily: MONO_FONT,
lineHeight: "1.5rem",
@@ -1174,6 +1181,12 @@ interface QueryCodeEditorProps
requirementBadge?: string;
editable?: boolean;
minHeight?: number;
/**
* When true the editor fills the height of its parent (which must be a
* bounded flex container) and scrolls internally instead of growing with
* its content.
*/
fill?: boolean;
showCopyButton?: boolean;
showLineNumbers?: boolean;
onChange: (value: string) => void;
@@ -1193,6 +1206,7 @@ export const QueryCodeEditor = ({
requirementBadge,
editable = true,
minHeight = 320,
fill = false,
showCopyButton = false,
showLineNumbers = true,
onChange,
@@ -1202,7 +1216,7 @@ export const QueryCodeEditor = ({
const { resolvedTheme } = useTheme();
const [copied, setCopied] = useState(false);
const isDarkMode = resolvedTheme === "dark";
const editorTheme = createEditorTheme({ isDarkMode, minHeight });
const editorTheme = createEditorTheme({ isDarkMode, minHeight, fill });
const editorHighlightStyle = isDarkMode
? darkHighlightStyle
: lightHighlightStyle;
@@ -1261,12 +1275,13 @@ export const QueryCodeEditor = ({
data-show-line-numbers={String(showLineNumbers)}
className={cn(
"border-border-neutral-secondary bg-bg-neutral-primary overflow-hidden rounded-xl border",
fill && "flex min-h-0 flex-1 flex-col",
invalid && "border-border-error-primary",
className,
)}
{...props}
>
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex items-center justify-between border-b px-4 py-2">
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex shrink-0 items-center justify-between border-b px-4 py-2">
{visibleLabel ? (
<span className="text-text-neutral-secondary text-xs font-medium">
{visibleLabel}
@@ -1303,6 +1318,7 @@ export const QueryCodeEditor = ({
<CodeMirror
value={value}
theme={editorTheme}
className={cn(fill && "min-h-0 flex-1")}
basicSetup={{
foldGutter: false,
highlightActiveLine: false,
@@ -20,6 +20,11 @@ interface ResourceMetadataPanelProps {
* neither is available. Reused by the resource detail view and the finding
* detail drawer (compliance requirement findings view) to keep the UX
* consistent across surfaces.
*
* Layout contract: the parent must be a bounded flex column (e.g.
* `flex min-h-0 flex-1 flex-col overflow-hidden`). The embedded editor
* fills that height and scrolls internally, so JSON-heavy resources do
* not push the surrounding chrome (drawer, page) into a double scroll.
*/
export function ResourceMetadataPanel({
metadata,
@@ -57,7 +62,7 @@ export function ResourceMetadataPanel({
value={formattedMetadata}
copyValue={formattedMetadata}
editable={false}
minHeight={220}
fill
showCopyButton
onChange={() => {}}
/>
+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.",
});
};
/**
+27 -11
View File
@@ -332,49 +332,65 @@
display: none; /* Chrome, Safari, Opera */
}
/* Minimal scrollbar styles */
.minimal-scrollbar {
/* Minimal scrollbar styles
*
* The descendant selectors target `.cm-scroller` so that CodeMirror
* editors which receive `.minimal-scrollbar` on their `.cm-editor`
* wrapper also style their inner scroller (the element that actually
* overflows when the editor fills a bounded container).
*/
.minimal-scrollbar,
.minimal-scrollbar .cm-scroller {
scrollbar-width: thin; /* Firefox */
scrollbar-color: rgb(203 213 225 / 0.5) transparent; /* thumb and track for Firefox */
}
.minimal-scrollbar:hover {
.minimal-scrollbar:hover,
.minimal-scrollbar .cm-scroller:hover {
scrollbar-color: rgb(148 163 184 / 0.7) transparent; /* darker thumb on hover */
}
/* Webkit browsers (Chrome, Safari, Edge) */
.minimal-scrollbar::-webkit-scrollbar {
.minimal-scrollbar::-webkit-scrollbar,
.minimal-scrollbar .cm-scroller::-webkit-scrollbar {
width: 6px;
}
.minimal-scrollbar::-webkit-scrollbar-track {
.minimal-scrollbar::-webkit-scrollbar-track,
.minimal-scrollbar .cm-scroller::-webkit-scrollbar-track {
background: transparent;
}
.minimal-scrollbar::-webkit-scrollbar-thumb {
.minimal-scrollbar::-webkit-scrollbar-thumb,
.minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb {
background-color: rgb(203 213 225 / 0.5);
border-radius: 3px;
transition: background-color 0.2s ease;
}
.minimal-scrollbar::-webkit-scrollbar-thumb:hover {
.minimal-scrollbar::-webkit-scrollbar-thumb:hover,
.minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb:hover {
background-color: rgb(148 163 184 / 0.7);
}
/* Dark mode */
.dark .minimal-scrollbar {
.dark .minimal-scrollbar,
.dark .minimal-scrollbar .cm-scroller {
scrollbar-color: rgb(71 85 105 / 0.5) transparent;
}
.dark .minimal-scrollbar:hover {
.dark .minimal-scrollbar:hover,
.dark .minimal-scrollbar .cm-scroller:hover {
scrollbar-color: rgb(100 116 139 / 0.7) transparent;
}
.dark .minimal-scrollbar::-webkit-scrollbar-thumb {
.dark .minimal-scrollbar::-webkit-scrollbar-thumb,
.dark .minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb {
background-color: rgb(71 85 105 / 0.5);
}
.dark .minimal-scrollbar::-webkit-scrollbar-thumb:hover {
.dark .minimal-scrollbar::-webkit-scrollbar-thumb:hover,
.dark .minimal-scrollbar .cm-scroller::-webkit-scrollbar-thumb:hover {
background-color: rgb(100 116 139 / 0.7);
}
Generated
+1 -1
View File
@@ -3241,7 +3241,7 @@ wheels = [
[[package]]
name = "prowler"
version = "5.28.0"
version = "5.28.1"
source = { editable = "." }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },