mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-11 05:46:05 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d086a624a0 | |||
| a7c2b6cbce | |||
| 5da5848509 | |||
| 1a397d1024 | |||
| d9c849bed0 | |||
| a33c301fcc | |||
| e65bf81bf8 | |||
| ea419b49d8 | |||
| 5900d2314a | |||
| 3116352931 | |||
| d54bf452ca | |||
| 8d8f551664 | |||
| ae961e5065 | |||
| 3a096b1750 | |||
| 6f01041178 | |||
| 13e2ede763 | |||
| c53ddfd532 | |||
| f86bd7b52e |
@@ -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
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.29.0
|
||||
version: 1.29.1
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -15921,6 +15921,12 @@ class TestFindingGroupViewSet:
|
||||
assert attrs["fail_count"] == 0
|
||||
assert attrs["resources_total"] == 1
|
||||
assert attrs["resources_fail"] == 0
|
||||
# check_title / check_description are resolved post-pagination from the
|
||||
# summary table, not from the finding's check_metadata.
|
||||
assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs"
|
||||
assert (
|
||||
attrs["check_description"] == "EC2 instances should use private IPs only."
|
||||
)
|
||||
|
||||
def test_finding_groups_status_pass_when_no_fail(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
@@ -17162,6 +17168,12 @@ class TestFindingGroupViewSet:
|
||||
assert attrs["fail_count"] == 0
|
||||
assert attrs["resources_total"] == 1
|
||||
assert attrs["resources_fail"] == 0
|
||||
# check_title / check_description are resolved post-pagination from the
|
||||
# summary table, not from the finding's check_metadata.
|
||||
assert attrs["check_title"] == "Ensure EC2 instances do not have public IPs"
|
||||
assert (
|
||||
attrs["check_description"] == "EC2 instances should use private IPs only."
|
||||
)
|
||||
|
||||
def test_finding_groups_latest_status_in_filter(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
@@ -17419,18 +17431,20 @@ class TestFindingGroupViewSet:
|
||||
check_ids = [item["id"] for item in data]
|
||||
assert check_ids == sorted(check_ids)
|
||||
|
||||
def test_finding_groups_latest_sort_by_check_title(
|
||||
def test_finding_groups_latest_sort_by_check_title_not_supported(
|
||||
self, authenticated_client, finding_groups_fixture
|
||||
):
|
||||
"""Test /latest supports sorting by check_title."""
|
||||
"""check_title is not a sortable field for finding groups.
|
||||
|
||||
Titles live in the TOASTed check_metadata blob and are resolved after
|
||||
pagination from the summary table, so they cannot drive DB-level
|
||||
ordering. Requesting that sort is rejected.
|
||||
"""
|
||||
response = authenticated_client.get(
|
||||
reverse("finding-group-latest"),
|
||||
{"sort": "check_title"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
check_titles = [item["attributes"]["check_title"] for item in data]
|
||||
assert check_titles == sorted(check_titles)
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"endpoint_name", ["finding-group-list", "finding-group-latest"]
|
||||
|
||||
@@ -7369,6 +7369,15 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
|
||||
# `check_title` / `check_description` are intentionally NOT resolved
|
||||
# here. They live in the large JSONB `check_metadata` blob (TOASTed),
|
||||
# so reading them per finding row is very expensive, and pulling them
|
||||
# in via a correlated subquery makes Django add the subquery to GROUP
|
||||
# BY, which re-evaluates it once per input row. They are identical for
|
||||
# every finding of a `check_id`, so `_post_process_aggregation` fills
|
||||
# them from the summary table's plain columns in a single batched
|
||||
# lookup scoped to the paginated page.
|
||||
|
||||
# `pass_count`, `fail_count` and `manual_count` only count non-muted
|
||||
# findings. Muted findings are tracked separately via the
|
||||
# `*_muted_count` fields.
|
||||
@@ -7439,15 +7448,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
agg_failing_since=Min(
|
||||
"first_seen_at", filter=Q(status="FAIL", muted=False)
|
||||
),
|
||||
check_title=Coalesce(
|
||||
Max(KeyTextTransform("checktitle", "check_metadata")),
|
||||
Max(KeyTextTransform("CheckTitle", "check_metadata")),
|
||||
Max(KeyTextTransform("Checktitle", "check_metadata")),
|
||||
),
|
||||
check_description=Coalesce(
|
||||
Max(KeyTextTransform("description", "check_metadata")),
|
||||
Max(KeyTextTransform("Description", "check_metadata")),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
# Group is muted only if it has zero non-muted findings.
|
||||
@@ -7503,9 +7503,38 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
- Computes aggregated status (FAIL > PASS > MANUAL); the orthogonal
|
||||
``muted`` boolean is already on the row from the SQL aggregation
|
||||
- Converts provider string to list
|
||||
- Fills check_title / check_description for the findings path
|
||||
"""
|
||||
rows = list(aggregated_data)
|
||||
|
||||
# The findings-aggregation path omits check_title / check_description
|
||||
# (they sit in TOASTed JSONB; see _aggregate_findings). Fill them from
|
||||
# the summary table's plain columns in one query scoped to this page.
|
||||
# The summary-aggregation path already carries them, so skip it there.
|
||||
if rows and "check_title" not in rows[0]:
|
||||
check_ids = [row["check_id"] for row in rows]
|
||||
role = get_role(self.request.user, self.request.tenant_id)
|
||||
summaries = FindingGroupDailySummary.objects.filter(
|
||||
tenant_id=self.request.tenant_id,
|
||||
check_id__in=check_ids,
|
||||
)
|
||||
# Scope to the user's providers, mirroring get_queryset(), so titles
|
||||
# are read only from providers the user can see.
|
||||
if not role.unlimited_visibility:
|
||||
summaries = summaries.filter(provider__in=get_providers(role))
|
||||
metadata_by_check = {
|
||||
item["check_id"]: item
|
||||
for item in summaries.order_by("check_id", "-inserted_at")
|
||||
.distinct("check_id")
|
||||
.values("check_id", "check_title", "check_description")
|
||||
}
|
||||
for row in rows:
|
||||
metadata = metadata_by_check.get(row["check_id"], {})
|
||||
row["check_title"] = metadata.get("check_title")
|
||||
row["check_description"] = metadata.get("check_description")
|
||||
|
||||
results = []
|
||||
for row in aggregated_data:
|
||||
for row in rows:
|
||||
# Convert severity order back to string
|
||||
severity_order = row.get("severity_order", 1)
|
||||
row["severity"] = SEVERITY_ORDER_REVERSE.get(
|
||||
@@ -7551,7 +7580,6 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
_FINDING_GROUP_SORT_MAP = {
|
||||
"check_id": "check_id",
|
||||
"check_title": "check_title",
|
||||
"severity": "severity_order",
|
||||
"status": "status_order",
|
||||
"muted": "muted",
|
||||
|
||||
Generated
+5
-4
@@ -4410,8 +4410,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.27.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
|
||||
version = "5.28.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.28#3a096b17504fe8f3f743fdc44148d35b9723df92" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4484,6 +4484,7 @@ dependencies = [
|
||||
{ name = "pygithub" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "scaleway" },
|
||||
{ name = "schema" },
|
||||
{ name = "shodan" },
|
||||
{ name = "slack-sdk" },
|
||||
@@ -4494,7 +4495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.29.0"
|
||||
version = "1.29.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" },
|
||||
|
||||
@@ -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".
|
||||
|
||||

|
||||
|
||||
3. Click "Add Provider".
|
||||
|
||||

|
||||
|
||||
4. Select "Okta".
|
||||
|
||||

|
||||
|
||||
5. Enter the **Org Domain** of the target Okta organization and an optional alias, then click "Next".
|
||||
|
||||

|
||||
|
||||
<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".
|
||||
|
||||

|
||||
|
||||
<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
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
-41
@@ -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": ""
|
||||
}
|
||||
-43
@@ -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)
|
||||
-43
@@ -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."
|
||||
}
|
||||
-71
@@ -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
|
||||
@@ -949,7 +949,7 @@ class AzureProvider(Provider):
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
)
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(get_azure_identity())
|
||||
asyncio.run(get_azure_identity())
|
||||
|
||||
# Managed identities only can be assigned resource, resource group and subscription scope permissions
|
||||
elif managed_identity_auth:
|
||||
|
||||
+1
-1
@@ -34,5 +34,5 @@
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "This check passes if SMB channel encryption is set to a secure algorithm."
|
||||
"Notes": "This check passes only if every SMB channel encryption algorithm allowed on the file shares is in the recommended list, which is configurable via azure.recommended_smb_channel_encryption_algorithms and defaults to AES-256-GCM only, as required by CIS."
|
||||
}
|
||||
|
||||
+21
-17
@@ -1,32 +1,38 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_Azure
|
||||
from prowler.providers.azure.services.storage.storage_client import storage_client
|
||||
|
||||
SECURE_ENCRYPTION_ALGORITHMS = ["AES-256-GCM"]
|
||||
DEFAULT_SECURE_ENCRYPTION_ALGORITHMS = ["AES-256-GCM"]
|
||||
|
||||
|
||||
class storage_smb_channel_encryption_with_secure_algorithm(Check):
|
||||
"""
|
||||
Ensure SMB channel encryption for file shares is set to the recommended algorithm (AES-256-GCM or higher).
|
||||
Ensure SMB channel encryption for file shares only allows secure algorithms (AES-256-GCM or higher by default).
|
||||
|
||||
The list of allowed algorithms is configurable via
|
||||
azure.recommended_smb_channel_encryption_algorithms in the Prowler configuration file.
|
||||
|
||||
This check evaluates whether SMB file shares are configured to use only the recommended SMB channel encryption algorithms.
|
||||
- PASS: Storage account has the recommended SMB channel encryption (AES-256-GCM or higher) enabled for file shares.
|
||||
- FAIL: Storage account does not have the recommended SMB channel encryption enabled for file shares or uses an unsupported algorithm.
|
||||
- PASS: Storage account only allows secure SMB channel encryption algorithms for file shares.
|
||||
- FAIL: Storage account does not have SMB channel encryption enabled, or it allows at least one algorithm that is not in the recommended list.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_Azure]:
|
||||
findings = []
|
||||
secure_encryption_algorithms = storage_client.audit_config.get(
|
||||
"recommended_smb_channel_encryption_algorithms",
|
||||
DEFAULT_SECURE_ENCRYPTION_ALGORITHMS,
|
||||
)
|
||||
for subscription, storage_accounts in storage_client.storage_accounts.items():
|
||||
subscription_name = storage_client.subscriptions.get(
|
||||
subscription, subscription
|
||||
)
|
||||
for account in storage_accounts:
|
||||
if account.file_service_properties:
|
||||
channel_encryption = (
|
||||
account.file_service_properties.smb_protocol_settings.channel_encryption
|
||||
)
|
||||
pretty_current_algorithms = (
|
||||
", ".join(
|
||||
account.file_service_properties.smb_protocol_settings.channel_encryption
|
||||
)
|
||||
if account.file_service_properties.smb_protocol_settings.channel_encryption
|
||||
else "none"
|
||||
", ".join(channel_encryption) if channel_encryption else "none"
|
||||
)
|
||||
report = Check_Report_Azure(
|
||||
metadata=self.metadata(),
|
||||
@@ -35,20 +41,18 @@ class storage_smb_channel_encryption_with_secure_algorithm(Check):
|
||||
report.subscription = subscription
|
||||
report.resource_name = account.name
|
||||
|
||||
if (
|
||||
not account.file_service_properties.smb_protocol_settings.channel_encryption
|
||||
):
|
||||
if not channel_encryption:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) does not have SMB channel encryption enabled for file shares."
|
||||
elif any(
|
||||
algorithm in SECURE_ENCRYPTION_ALGORITHMS
|
||||
for algorithm in account.file_service_properties.smb_protocol_settings.channel_encryption
|
||||
elif all(
|
||||
algorithm in secure_encryption_algorithms
|
||||
for algorithm in channel_encryption
|
||||
):
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) has a secure algorithm for SMB channel encryption ({', '.join(SECURE_ENCRYPTION_ALGORITHMS)}) enabled for file shares since it supports {pretty_current_algorithms}."
|
||||
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) only allows secure algorithms for SMB channel encryption on file shares since it supports {pretty_current_algorithms}."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) does not have SMB channel encryption with a secure algorithm for file shares since it supports {pretty_current_algorithms}."
|
||||
report.status_extended = f"Storage account {account.name} from subscription {subscription_name} ({subscription}) allows insecure algorithms for SMB channel encryption on file shares since it supports {pretty_current_algorithms} and only {', '.join(secure_encryption_algorithms)} is recommended."
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
@@ -87,9 +87,15 @@ class Compute(GCPService):
|
||||
.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
|
||||
)
|
||||
for item in response["commonInstanceMetadata"].get("items", []):
|
||||
if item["key"] == "enable-oslogin" and item["value"] == "TRUE":
|
||||
if (
|
||||
item["key"] == "enable-oslogin"
|
||||
and item["value"].lower() == "true"
|
||||
):
|
||||
enable_oslogin = True
|
||||
if item["key"] == "enable-oslogin-2fa" and item["value"] == "TRUE":
|
||||
if (
|
||||
item["key"] == "enable-oslogin-2fa"
|
||||
and item["value"].lower() == "true"
|
||||
):
|
||||
enable_oslogin_2fa = True
|
||||
self.compute_projects.append(
|
||||
Project(
|
||||
|
||||
@@ -1073,7 +1073,7 @@ class M365Provider(Provider):
|
||||
organization_info = await client.organization.get()
|
||||
identity.tenant_id = organization_info.value[0].id
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(get_m365_identity(identity))
|
||||
asyncio.run(get_m365_identity(identity))
|
||||
return identity
|
||||
|
||||
@staticmethod
|
||||
@@ -1261,9 +1261,7 @@ class M365Provider(Provider):
|
||||
result = await client.domains.get()
|
||||
return result.value
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
verify_certificate()
|
||||
)
|
||||
result = asyncio.run(verify_certificate())
|
||||
if not result:
|
||||
raise M365NotValidCertificateContentError(
|
||||
file=os.path.basename(__file__),
|
||||
@@ -1284,9 +1282,7 @@ class M365Provider(Provider):
|
||||
result = await client.domains.get()
|
||||
return result.value
|
||||
|
||||
result = asyncio.get_event_loop().run_until_complete(
|
||||
verify_certificate()
|
||||
)
|
||||
result = asyncio.run(verify_certificate())
|
||||
if not result:
|
||||
raise M365NotValidCertificatePathError(
|
||||
file=os.path.basename(__file__),
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
version = "5.28.0"
|
||||
version = "5.28.1"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
-157
@@ -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
|
||||
-153
@@ -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
|
||||
@@ -1,4 +1,5 @@
|
||||
from unittest.mock import patch
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -86,6 +87,7 @@ class TestAzureProvider:
|
||||
"python_latest_version": "3.12",
|
||||
"java_latest_version": "17",
|
||||
"recommended_minimal_tls_versions": ["1.2", "1.3"],
|
||||
"recommended_smb_channel_encryption_algorithms": ["AES-256-GCM"],
|
||||
"vm_backup_min_daily_retention_days": 7,
|
||||
"desired_vm_sku_sizes": [
|
||||
"Standard_A8_v2",
|
||||
@@ -721,3 +723,88 @@ class TestAzureProviderSetupIdentitySubscriptions:
|
||||
first_id: shared_name,
|
||||
second_id: shared_name,
|
||||
}
|
||||
|
||||
|
||||
class TestAzureProviderSetupIdentityEventLoop:
|
||||
"""Regression for the Celery worker scenario where
|
||||
asyncio.get_event_loop() raised "There is no current event loop in
|
||||
thread 'MainThread'." on Python 3.12. setup_identity now uses
|
||||
asyncio.run(), which creates its own loop and must work without a
|
||||
pre-existing one in the current thread."""
|
||||
|
||||
@staticmethod
|
||||
def _mock_subscription(display_name, subscription_id):
|
||||
mock_subscription = MagicMock()
|
||||
mock_subscription.display_name = display_name
|
||||
mock_subscription.subscription_id = subscription_id
|
||||
return mock_subscription
|
||||
|
||||
@staticmethod
|
||||
def _build_subscriptions_client_mock(subscriptions):
|
||||
subscriptions_operations = MagicMock()
|
||||
subscriptions_operations.list = MagicMock(return_value=subscriptions)
|
||||
subscriptions_operations.get = MagicMock()
|
||||
|
||||
tenants_operations = MagicMock()
|
||||
tenants_operations.list = MagicMock(return_value=[])
|
||||
|
||||
client_instance = MagicMock()
|
||||
client_instance.subscriptions = subscriptions_operations
|
||||
client_instance.tenants = tenants_operations
|
||||
return MagicMock(return_value=client_instance)
|
||||
|
||||
@staticmethod
|
||||
def _build_provider():
|
||||
with patch.object(AzureProvider, "__init__", return_value=None):
|
||||
azure_provider = AzureProvider()
|
||||
azure_provider._session = MagicMock()
|
||||
azure_provider._region_config = AzureRegionConfig(
|
||||
name="AzureCloud",
|
||||
authority=None,
|
||||
base_url="https://management.azure.com",
|
||||
credential_scopes=["https://management.azure.com/.default"],
|
||||
)
|
||||
return azure_provider
|
||||
|
||||
def test_setup_identity_succeeds_without_active_event_loop(self):
|
||||
sub_id = str(uuid4())
|
||||
subscriptions_client = self._build_subscriptions_client_mock(
|
||||
[self._mock_subscription("Sub", sub_id)]
|
||||
)
|
||||
|
||||
graph_client = MagicMock()
|
||||
graph_client.domains.get = AsyncMock(return_value=MagicMock(value=[]))
|
||||
graph_client.me.get = AsyncMock(return_value=None)
|
||||
|
||||
# Simulate the Celery worker state: no event loop registered for the
|
||||
# current thread. Before the fix this combination triggered
|
||||
# `RuntimeError: There is no current event loop in thread 'MainThread'.`
|
||||
# on Python 3.12 from asyncio.get_event_loop().
|
||||
asyncio.set_event_loop(None)
|
||||
try:
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.azure.azure_provider.GraphServiceClient",
|
||||
return_value=graph_client,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.azure.azure_provider.SubscriptionClient",
|
||||
subscriptions_client,
|
||||
),
|
||||
):
|
||||
azure_provider = self._build_provider()
|
||||
identity = azure_provider.setup_identity(
|
||||
az_cli_auth=False,
|
||||
sp_env_auth=True,
|
||||
browser_auth=False,
|
||||
managed_identity_auth=False,
|
||||
subscription_ids=[],
|
||||
client_id="00000000-0000-0000-0000-000000000000",
|
||||
)
|
||||
finally:
|
||||
# Re-arm a loop for sibling tests that may rely on the default.
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
assert isinstance(identity, AzureIdentityInfo)
|
||||
assert identity.subscriptions == {sub_id: "Sub"}
|
||||
graph_client.domains.get.assert_awaited_once()
|
||||
|
||||
+128
-2
@@ -20,6 +20,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
|
||||
def test_no_storage_accounts(self):
|
||||
storage_client = mock.MagicMock()
|
||||
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||
storage_client.audit_config = {}
|
||||
storage_client.storage_accounts = {}
|
||||
with (
|
||||
mock.patch(
|
||||
@@ -44,6 +45,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
|
||||
storage_account_name = "Test Storage Account"
|
||||
storage_client = mock.MagicMock()
|
||||
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||
storage_client.audit_config = {}
|
||||
storage_client.storage_accounts = {
|
||||
AZURE_SUBSCRIPTION_ID: [
|
||||
Account(
|
||||
@@ -97,6 +99,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
|
||||
)
|
||||
storage_client = mock.MagicMock()
|
||||
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||
storage_client.audit_config = {}
|
||||
storage_client.storage_accounts = {
|
||||
AZURE_SUBSCRIPTION_ID: [
|
||||
Account(
|
||||
@@ -154,6 +157,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
|
||||
)
|
||||
storage_client = mock.MagicMock()
|
||||
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||
storage_client.audit_config = {}
|
||||
storage_client.storage_accounts = {
|
||||
AZURE_SUBSCRIPTION_ID: [
|
||||
Account(
|
||||
@@ -194,7 +198,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].status_extended == (
|
||||
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have SMB channel encryption with a secure algorithm for file shares since it supports AES-128-GCM."
|
||||
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows insecure algorithms for SMB channel encryption on file shares since it supports AES-128-GCM and only AES-256-GCM is recommended."
|
||||
)
|
||||
|
||||
def test_recommended_encryption(self):
|
||||
@@ -211,6 +215,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
|
||||
)
|
||||
storage_client = mock.MagicMock()
|
||||
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||
storage_client.audit_config = {}
|
||||
storage_client.storage_accounts = {
|
||||
AZURE_SUBSCRIPTION_ID: [
|
||||
Account(
|
||||
@@ -251,5 +256,126 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm:
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].status_extended == (
|
||||
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has a secure algorithm for SMB channel encryption (AES-256-GCM) enabled for file shares since it supports AES-256-GCM."
|
||||
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} only allows secure algorithms for SMB channel encryption on file shares since it supports AES-256-GCM."
|
||||
)
|
||||
|
||||
def test_recommended_algorithm_mixed_with_weak_algorithm(self):
|
||||
storage_account_id = str(uuid4())
|
||||
storage_account_name = "Test Storage Account"
|
||||
file_service_properties = FileServiceProperties(
|
||||
id="id1",
|
||||
name="fs1",
|
||||
type="type1",
|
||||
share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7),
|
||||
smb_protocol_settings=SMBProtocolSettings(
|
||||
channel_encryption=["AES-128-CCM", "AES-256-GCM"], supported_versions=[]
|
||||
),
|
||||
)
|
||||
storage_client = mock.MagicMock()
|
||||
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||
storage_client.audit_config = {}
|
||||
storage_client.storage_accounts = {
|
||||
AZURE_SUBSCRIPTION_ID: [
|
||||
Account(
|
||||
id=storage_account_id,
|
||||
name=storage_account_name,
|
||||
resouce_group_name="rg",
|
||||
enable_https_traffic_only=False,
|
||||
infrastructure_encryption=False,
|
||||
allow_blob_public_access=False,
|
||||
network_rule_set=NetworkRuleSet(
|
||||
bypass="AzureServices", default_action="Allow"
|
||||
),
|
||||
encryption_type="None",
|
||||
minimum_tls_version="TLS1_2",
|
||||
key_expiration_period_in_days=None,
|
||||
location="westeurope",
|
||||
private_endpoint_connections=[],
|
||||
file_service_properties=file_service_properties,
|
||||
)
|
||||
]
|
||||
}
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_azure_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm.storage_client",
|
||||
new=storage_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm import (
|
||||
storage_smb_channel_encryption_with_secure_algorithm,
|
||||
)
|
||||
|
||||
check = storage_smb_channel_encryption_with_secure_algorithm()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].status_extended == (
|
||||
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows insecure algorithms for SMB channel encryption on file shares since it supports AES-128-CCM, AES-256-GCM and only AES-256-GCM is recommended."
|
||||
)
|
||||
|
||||
def test_custom_recommended_algorithms_from_config(self):
|
||||
storage_account_id = str(uuid4())
|
||||
storage_account_name = "Test Storage Account"
|
||||
file_service_properties = FileServiceProperties(
|
||||
id="id1",
|
||||
name="fs1",
|
||||
type="type1",
|
||||
share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7),
|
||||
smb_protocol_settings=SMBProtocolSettings(
|
||||
channel_encryption=["AES-128-GCM", "AES-256-GCM"], supported_versions=[]
|
||||
),
|
||||
)
|
||||
storage_client = mock.MagicMock()
|
||||
storage_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||
storage_client.audit_config = {
|
||||
"recommended_smb_channel_encryption_algorithms": [
|
||||
"AES-128-GCM",
|
||||
"AES-256-GCM",
|
||||
]
|
||||
}
|
||||
storage_client.storage_accounts = {
|
||||
AZURE_SUBSCRIPTION_ID: [
|
||||
Account(
|
||||
id=storage_account_id,
|
||||
name=storage_account_name,
|
||||
resouce_group_name="rg",
|
||||
enable_https_traffic_only=False,
|
||||
infrastructure_encryption=False,
|
||||
allow_blob_public_access=False,
|
||||
network_rule_set=NetworkRuleSet(
|
||||
bypass="AzureServices", default_action="Allow"
|
||||
),
|
||||
encryption_type="None",
|
||||
minimum_tls_version="TLS1_2",
|
||||
key_expiration_period_in_days=None,
|
||||
location="westeurope",
|
||||
private_endpoint_connections=[],
|
||||
file_service_properties=file_service_properties,
|
||||
)
|
||||
]
|
||||
}
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_azure_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm.storage_client",
|
||||
new=storage_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.azure.services.storage.storage_smb_channel_encryption_with_secure_algorithm.storage_smb_channel_encryption_with_secure_algorithm import (
|
||||
storage_smb_channel_encryption_with_secure_algorithm,
|
||||
)
|
||||
|
||||
check = storage_smb_channel_encryption_with_secure_algorithm()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].status_extended == (
|
||||
f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} only allows secure algorithms for SMB channel encryption on file shares since it supports AES-128-GCM, AES-256-GCM."
|
||||
)
|
||||
|
||||
@@ -126,7 +126,11 @@ def mock_api_projects_calls(client: MagicMock):
|
||||
"etag": "BwWWja0YfJA=",
|
||||
"version": 3,
|
||||
}
|
||||
# Used by compute client and cloudresourcemanager
|
||||
# Used by compute client and cloudresourcemanager.
|
||||
# `enable-oslogin` covers the documented uppercase form (TRUE);
|
||||
# `enable-oslogin-2fa` covers the lowercase form (true) that GCP's
|
||||
# `constraints/compute.requireOsLogin` org-policy controller writes
|
||||
# in production. The service-layer parser must handle both casings.
|
||||
client.projects().get().execute.return_value = {
|
||||
"projectNumber": "123456789012",
|
||||
"commonInstanceMetadata": {
|
||||
@@ -139,6 +143,10 @@ def mock_api_projects_calls(client: MagicMock):
|
||||
"key": "enable-oslogin",
|
||||
"value": "FALSE",
|
||||
},
|
||||
{
|
||||
"key": "enable-oslogin-2fa",
|
||||
"value": "true",
|
||||
},
|
||||
{
|
||||
"key": "testing-key",
|
||||
"value": "TRUE",
|
||||
|
||||
@@ -34,6 +34,7 @@ class TestComputeService:
|
||||
assert len(compute_client.compute_projects) == 1
|
||||
assert compute_client.compute_projects[0].id == GCP_PROJECT_ID
|
||||
assert compute_client.compute_projects[0].enable_oslogin
|
||||
assert compute_client.compute_projects[0].enable_oslogin_2fa
|
||||
|
||||
assert len(compute_client.instances) == 2
|
||||
assert compute_client.instances[0].name == "instance1"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -1535,19 +1536,17 @@ class TestM365Provider:
|
||||
TENANT_ID, CLIENT_ID, None, b"fake_certificate_data", certificate_path
|
||||
)
|
||||
|
||||
@patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop")
|
||||
@patch("prowler.providers.m365.m365_provider.asyncio.run")
|
||||
@patch("prowler.providers.m365.m365_provider.GraphServiceClient")
|
||||
@patch("prowler.providers.m365.m365_provider.CertificateCredential")
|
||||
def test_verify_client_certificate_content_success(
|
||||
self, mock_cert_cred, mock_graph, mock_loop
|
||||
self, mock_cert_cred, mock_graph, mock_asyncio_run
|
||||
):
|
||||
"""Test verify_client method with valid certificate content"""
|
||||
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
|
||||
|
||||
# Mock the async call
|
||||
mock_loop_instance = MagicMock()
|
||||
mock_loop.return_value = mock_loop_instance
|
||||
mock_loop_instance.run_until_complete.return_value = [{"id": "domain.com"}]
|
||||
# Mock the async call result
|
||||
mock_asyncio_run.return_value = [{"id": "domain.com"}]
|
||||
|
||||
# Mock credential and graph client
|
||||
mock_credential = MagicMock()
|
||||
@@ -1563,19 +1562,17 @@ class TestM365Provider:
|
||||
mock_cert_cred.assert_called_once()
|
||||
mock_graph.assert_called_once_with(credentials=mock_credential)
|
||||
|
||||
@patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop")
|
||||
@patch("prowler.providers.m365.m365_provider.asyncio.run")
|
||||
@patch("prowler.providers.m365.m365_provider.GraphServiceClient")
|
||||
@patch("prowler.providers.m365.m365_provider.CertificateCredential")
|
||||
def test_verify_client_certificate_content_failure(
|
||||
self, mock_cert_cred, mock_graph, mock_loop
|
||||
self, mock_cert_cred, mock_graph, mock_asyncio_run
|
||||
):
|
||||
"""Test verify_client method with certificate content that fails validation"""
|
||||
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
|
||||
|
||||
# Mock the async call to return empty result (invalid certificate)
|
||||
mock_loop_instance = MagicMock()
|
||||
mock_loop.return_value = mock_loop_instance
|
||||
mock_loop_instance.run_until_complete.return_value = None
|
||||
mock_asyncio_run.return_value = None
|
||||
|
||||
# Mock credential and graph client
|
||||
mock_credential = MagicMock()
|
||||
@@ -1591,19 +1588,17 @@ class TestM365Provider:
|
||||
assert "certificate content is not valid" in str(exception.value)
|
||||
|
||||
@patch("builtins.open", mock_open(read_data=b"fake_certificate_data"))
|
||||
@patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop")
|
||||
@patch("prowler.providers.m365.m365_provider.asyncio.run")
|
||||
@patch("prowler.providers.m365.m365_provider.GraphServiceClient")
|
||||
@patch("prowler.providers.m365.m365_provider.CertificateCredential")
|
||||
def test_verify_client_certificate_path_success(
|
||||
self, mock_cert_cred, mock_graph, mock_loop
|
||||
self, mock_cert_cred, mock_graph, mock_asyncio_run
|
||||
):
|
||||
"""Test verify_client method with valid certificate path"""
|
||||
certificate_path = "/path/to/cert.pem"
|
||||
|
||||
# Mock the async call
|
||||
mock_loop_instance = MagicMock()
|
||||
mock_loop.return_value = mock_loop_instance
|
||||
mock_loop_instance.run_until_complete.return_value = [{"id": "domain.com"}]
|
||||
# Mock the async call result
|
||||
mock_asyncio_run.return_value = [{"id": "domain.com"}]
|
||||
|
||||
# Mock credential and graph client
|
||||
mock_credential = MagicMock()
|
||||
@@ -1618,19 +1613,17 @@ class TestM365Provider:
|
||||
mock_graph.assert_called_once_with(credentials=mock_credential)
|
||||
|
||||
@patch("builtins.open", mock_open(read_data=b"fake_certificate_data"))
|
||||
@patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop")
|
||||
@patch("prowler.providers.m365.m365_provider.asyncio.run")
|
||||
@patch("prowler.providers.m365.m365_provider.GraphServiceClient")
|
||||
@patch("prowler.providers.m365.m365_provider.CertificateCredential")
|
||||
def test_verify_client_certificate_path_failure(
|
||||
self, mock_cert_cred, mock_graph, mock_loop
|
||||
self, mock_cert_cred, mock_graph, mock_asyncio_run
|
||||
):
|
||||
"""Test verify_client method with certificate path that fails validation"""
|
||||
certificate_path = "/path/to/cert.pem"
|
||||
|
||||
# Mock the async call to return empty result (invalid certificate)
|
||||
mock_loop_instance = MagicMock()
|
||||
mock_loop.return_value = mock_loop_instance
|
||||
mock_loop_instance.run_until_complete.return_value = None
|
||||
mock_asyncio_run.return_value = None
|
||||
|
||||
# Mock credential and graph client
|
||||
mock_credential = MagicMock()
|
||||
@@ -1804,3 +1797,94 @@ class TestM365Provider:
|
||||
assert "Missing environment variable M365_CERTIFICATE_CONTENT" in str(
|
||||
exception.value
|
||||
)
|
||||
|
||||
|
||||
class TestM365ProviderEventLoop:
|
||||
"""Regression for Celery workers on Python 3.12 where
|
||||
asyncio.get_event_loop() raised
|
||||
`RuntimeError: There is no current event loop in thread 'MainThread'.`
|
||||
M365Provider.setup_identity and M365Provider.validate_static_credentials
|
||||
must work without a pre-existing loop in the current thread."""
|
||||
|
||||
def _without_event_loop(self, callable_):
|
||||
# Simulate the Celery worker state: no event loop registered for the
|
||||
# current thread.
|
||||
asyncio.set_event_loop(None)
|
||||
try:
|
||||
return callable_()
|
||||
finally:
|
||||
# Re-arm a loop so sibling tests that rely on the default don't
|
||||
# bleed into each other.
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
|
||||
def test_setup_identity_succeeds_without_active_event_loop(self):
|
||||
domain = MagicMock()
|
||||
domain.id = "tenant.onmicrosoft.com"
|
||||
domain.is_default = True
|
||||
|
||||
org = MagicMock()
|
||||
org.id = TENANT_ID
|
||||
|
||||
graph_client = MagicMock()
|
||||
graph_client.domains.get = AsyncMock(return_value=MagicMock(value=[domain]))
|
||||
graph_client.organization.get = AsyncMock(return_value=MagicMock(value=[org]))
|
||||
|
||||
session = MagicMock()
|
||||
# `setup_identity` reads `session.credentials[0]._credential.client_id`
|
||||
# when sp_env_auth is True to populate identity.identity_id.
|
||||
session.credentials = [MagicMock()]
|
||||
session.credentials[0]._credential.client_id = CLIENT_ID
|
||||
|
||||
def call():
|
||||
with patch(
|
||||
"prowler.providers.m365.m365_provider.GraphServiceClient",
|
||||
return_value=graph_client,
|
||||
):
|
||||
return M365Provider.setup_identity(
|
||||
sp_env_auth=True,
|
||||
browser_auth=False,
|
||||
az_cli_auth=False,
|
||||
certificate_auth=False,
|
||||
session=session,
|
||||
)
|
||||
|
||||
identity = self._without_event_loop(call)
|
||||
|
||||
assert isinstance(identity, M365IdentityInfo)
|
||||
assert identity.tenant_id == TENANT_ID
|
||||
graph_client.domains.get.assert_awaited_once()
|
||||
graph_client.organization.get.assert_awaited_once()
|
||||
|
||||
def test_verify_client_certificate_content_without_active_event_loop(self):
|
||||
# `verify_client` is the function the Sentry trace exercises through
|
||||
# certificate-based credential validation; it must run an asyncio
|
||||
# coroutine to call `client.domains.get()` and previously relied on
|
||||
# `asyncio.get_event_loop()`.
|
||||
graph_client = MagicMock()
|
||||
graph_client.domains.get = AsyncMock(
|
||||
return_value=MagicMock(value=[MagicMock()])
|
||||
)
|
||||
|
||||
def call():
|
||||
with (
|
||||
patch("prowler.providers.m365.m365_provider.CertificateCredential"),
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.GraphServiceClient",
|
||||
return_value=graph_client,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.base64.b64decode",
|
||||
return_value=b"cert-bytes",
|
||||
),
|
||||
):
|
||||
M365Provider.verify_client(
|
||||
tenant_id=TENANT_ID,
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=None,
|
||||
certificate_content="dGVzdA==",
|
||||
certificate_path=None,
|
||||
)
|
||||
|
||||
# Must not raise "There is no current event loop in thread 'MainThread'.".
|
||||
self._without_event_loop(call)
|
||||
graph_client.domains.get.assert_awaited_once()
|
||||
|
||||
+15
-2
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
+82
-19
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+132
-75
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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={() => {}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { downloadScanZip } from "./helper";
|
||||
|
||||
vi.mock("@/actions/scans", () => ({
|
||||
getComplianceCsv: vi.fn(),
|
||||
getCompliancePdfReport: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/task", () => ({
|
||||
getTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/auth.config", () => ({
|
||||
auth: vi.fn(),
|
||||
}));
|
||||
|
||||
const createToast = () => vi.fn();
|
||||
|
||||
const getAnchor = () => {
|
||||
const anchor = document.createElement("a");
|
||||
const clickMock = vi.spyOn(anchor, "click").mockImplementation(() => {});
|
||||
vi.spyOn(document, "createElement").mockReturnValue(anchor);
|
||||
return { anchor, clickMock };
|
||||
};
|
||||
|
||||
describe("downloadScanZip", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("preflights the report and starts a browser-native download when ready", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 204 })),
|
||||
);
|
||||
const { anchor, clickMock } = getAnchor();
|
||||
const toast = createToast();
|
||||
|
||||
await downloadScanZip("scan-123", toast);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/scans/scan-123/report?preflight=1",
|
||||
{
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
expect(anchor.href).toContain("/api/scans/scan-123/report");
|
||||
expect(anchor.download).toBe("scan-scan-123-report.zip");
|
||||
expect(clickMock).toHaveBeenCalledTimes(1);
|
||||
expect(toast).toHaveBeenCalledWith({
|
||||
title: "Download Started",
|
||||
description: "Your browser is downloading the scan report.",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the pending report message without starting a download", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response("{}", { status: 202 })),
|
||||
);
|
||||
const { clickMock } = getAnchor();
|
||||
const toast = createToast();
|
||||
|
||||
await downloadScanZip("scan-123", toast);
|
||||
|
||||
expect(clickMock).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledWith({
|
||||
title: "The report is still being generated",
|
||||
description: "Please try again in a few minutes.",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an error without starting a download when preflight fails", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response("not found", { status: 404 })),
|
||||
);
|
||||
const { clickMock } = getAnchor();
|
||||
const toast = createToast();
|
||||
|
||||
await downloadScanZip("scan-123", toast);
|
||||
|
||||
expect(clickMock).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Download Failed",
|
||||
description: "not found",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a generic error when preflight fails with an HTML gateway page", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
"<html><body><h1>504 Gateway Time-out</h1></body></html>",
|
||||
{
|
||||
status: 504,
|
||||
headers: { "content-type": "text/html" },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
const { clickMock } = getAnchor();
|
||||
const toast = createToast();
|
||||
|
||||
await downloadScanZip("scan-123", toast);
|
||||
|
||||
expect(clickMock).not.toHaveBeenCalled();
|
||||
expect(toast).toHaveBeenCalledWith({
|
||||
variant: "destructive",
|
||||
title: "Download Failed",
|
||||
description:
|
||||
"Unable to prepare the scan report. Please try again in a few minutes.",
|
||||
});
|
||||
});
|
||||
});
|
||||
+49
-32
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
getComplianceCsv,
|
||||
getCompliancePdfReport,
|
||||
getExportsZip,
|
||||
type ScanBinaryResult,
|
||||
} from "@/actions/scans";
|
||||
import { getTask } from "@/actions/task";
|
||||
@@ -102,48 +101,66 @@ export const getAuthUrl = (provider: AuthSocialProvider) => {
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const REPORT_PREPARATION_ERROR =
|
||||
"Unable to prepare the scan report. Please try again in a few minutes.";
|
||||
|
||||
const getPreflightErrorMessage = async (response: Response) => {
|
||||
const contentType = response.headers.get("content-type")?.toLowerCase() || "";
|
||||
|
||||
if (contentType.includes("text/html")) {
|
||||
return REPORT_PREPARATION_ERROR;
|
||||
}
|
||||
|
||||
return (await response.text()) || "An unknown error occurred.";
|
||||
};
|
||||
|
||||
export const downloadScanZip = async (
|
||||
scanId: string,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
) => {
|
||||
const result = await getExportsZip(scanId);
|
||||
const reportUrl = `/api/scans/${encodeURIComponent(scanId)}/report`;
|
||||
|
||||
if (result?.pending) {
|
||||
try {
|
||||
const preflightResponse = await fetch(`${reportUrl}?preflight=1`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (preflightResponse.status === 202) {
|
||||
toast({
|
||||
title: "The report is still being generated",
|
||||
description: "Please try again in a few minutes.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preflightResponse.ok) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Download Failed",
|
||||
description: await getPreflightErrorMessage(preflightResponse),
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "The report is still being generated",
|
||||
description: "Please try again in a few minutes.",
|
||||
variant: "destructive",
|
||||
title: "Download Failed",
|
||||
description: "Unable to start the report download. Please try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.success && result.data) {
|
||||
const binaryString = window.atob(result.data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = reportUrl;
|
||||
a.download = `scan-${scanId}-report.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
const blob = new Blob([bytes], { type: "application/zip" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = result.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
title: "Download Complete",
|
||||
description: "Your scan report has been downloaded successfully.",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Download Failed",
|
||||
description: result?.error || "An unknown error occurred.",
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: "Download Started",
|
||||
description: "Your browser is downloading the scan report.",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+27
-11
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user