From 40dd0e640b72ee2b9990c86e62103dc745c6bf7a Mon Sep 17 00:00:00 2001
From: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
Date: Mon, 4 May 2026 08:37:46 +0200
Subject: [PATCH 01/13] fix(sdk): strip http(s):// scheme from image registry
URLs (#10950)
---
prowler/CHANGELOG.md | 1 +
prowler/providers/image/image_provider.py | 20 ++++--
tests/providers/image/image_provider_test.py | 69 +++++++++++++++++++-
3 files changed, 84 insertions(+), 6 deletions(-)
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index cdf166674b..d49c7eb43c 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -22,6 +22,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- AWS SDK test isolation: autouse `mock_aws` fixture and leak detector in `conftest.py` to prevent tests from hitting real AWS endpoints, with idempotent organization setup for tests calling `set_mocked_aws_provider` multiple times [(#10605)](https://github.com/prowler-cloud/prowler/pull/10605)
- AWS `boto` user agent extra is now applied to every client [(#10944)](https://github.com/prowler-cloud/prowler/pull/10944)
+- Image provider connection check no longer fails with a misleading `host='https'` resolution error when the registry URL includes an `http://` or `https://` scheme prefix [(#10950)](https://github.com/prowler-cloud/prowler/pull/10950)
### 🔐 Security
diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py
index 7c9a9cbb08..07d02c04c9 100644
--- a/prowler/providers/image/image_provider.py
+++ b/prowler/providers/image/image_provider.py
@@ -329,12 +329,21 @@ class ImageProvider(Provider):
"""Image provider doesn't need a session since it uses Trivy directly"""
return None
+ @staticmethod
+ def _strip_scheme(value: str) -> str:
+ """Remove a leading http:// or https:// scheme from a registry input."""
+ for prefix in ("https://", "http://"):
+ if value.lower().startswith(prefix):
+ return value[len(prefix) :]
+ return value
+
@staticmethod
def _extract_registry(image: str) -> str | None:
"""Extract registry hostname from an image reference.
Returns None for Docker Hub images (no registry prefix).
"""
+ image = ImageProvider._strip_scheme(image)
parts = image.split("/")
if len(parts) >= 2 and ("." in parts[0] or ":" in parts[0]):
return parts[0]
@@ -348,6 +357,7 @@ class ImageProvider(Provider):
or "myregistry.com:5000" are registry URLs (dots in host, no slash).
Image references like "alpine:3.18" or "nginx" are not.
"""
+ image_uid = ImageProvider._strip_scheme(image_uid)
if "/" not in image_uid:
host_part = image_uid.split(":")[0]
if "." in host_part:
@@ -835,11 +845,9 @@ class ImageProvider(Provider):
image_ref = f"{repo}:{tag}"
else:
# OCI registries need the full host/repo:tag reference
- registry_host = self.registry.rstrip("/")
- for prefix in ("https://", "http://"):
- if registry_host.startswith(prefix):
- registry_host = registry_host[len(prefix) :]
- break
+ registry_host = ImageProvider._strip_scheme(
+ self.registry.rstrip("/")
+ )
image_ref = f"{registry_host}/{repo}:{tag}"
discovered_images.append(image_ref)
@@ -977,6 +985,8 @@ class ImageProvider(Provider):
if not image:
return Connection(is_connected=False, error="Image name is required")
+ image = ImageProvider._strip_scheme(image)
+
# Registry URL (bare hostname) → test via OCI catalog
if ImageProvider._is_registry_url(image):
return ImageProvider._test_registry_connection(
diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py
index 68b0c5d7a2..4462df6beb 100644
--- a/tests/providers/image/image_provider_test.py
+++ b/tests/providers/image/image_provider_test.py
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest
from prowler.lib.check.models import CheckReportImage
+from prowler.providers.common.provider import Provider
from prowler.providers.image.exceptions.exceptions import (
ImageInvalidConfigScannerError,
ImageInvalidNameError,
@@ -20,7 +21,6 @@ from prowler.providers.image.exceptions.exceptions import (
ImageScanError,
ImageTrivyBinaryNotFoundError,
)
-from prowler.providers.common.provider import Provider
from prowler.providers.image.image_provider import ImageProvider
from tests.providers.image.image_fixtures import (
SAMPLE_IMAGE_SHA,
@@ -345,6 +345,24 @@ class TestImageProvider:
)
mock_adapter.list_repositories.assert_called_once()
+ @patch("prowler.providers.image.image_provider.create_registry_adapter")
+ def test_test_connection_registry_url_with_https_scheme(self, mock_factory):
+ """Registry URL with https:// scheme is normalised before adapter creation."""
+ mock_adapter = MagicMock()
+ mock_adapter.list_repositories.return_value = ["repo1"]
+ mock_factory.return_value = mock_adapter
+
+ result = ImageProvider.test_connection(image="https://my-registry.example.com")
+
+ assert result.is_connected is True
+ mock_factory.assert_called_once_with(
+ registry_url="my-registry.example.com",
+ username=None,
+ password=None,
+ token=None,
+ )
+ mock_adapter.list_repositories.assert_called_once()
+
def test_build_status_extended(self):
"""Test status message content for different finding types."""
provider = _make_provider()
@@ -659,6 +677,27 @@ class TestImageProviderRegistryAuth:
assert "Docker login" in output
+class TestStripScheme:
+ @pytest.mark.parametrize(
+ "raw,expected",
+ [
+ ("https://my-registry.example.com", "my-registry.example.com"),
+ ("http://my-registry.example.com", "my-registry.example.com"),
+ ("HTTPS://My-Registry.Example.Com", "My-Registry.Example.Com"),
+ ("Http://localhost:5000", "localhost:5000"),
+ ("my-registry.example.com", "my-registry.example.com"),
+ ("https://", ""),
+ ("https://https://nested.example.com", "https://nested.example.com"),
+ (
+ "ftp://not-a-supported-scheme.example.com",
+ "ftp://not-a-supported-scheme.example.com",
+ ),
+ ],
+ )
+ def test_strip_scheme(self, raw, expected):
+ assert ImageProvider._strip_scheme(raw) == expected
+
+
class TestExtractRegistry:
def test_docker_hub_simple(self):
assert ImageProvider._extract_registry("alpine:3.18") is None
@@ -698,6 +737,24 @@ class TestExtractRegistry:
def test_bare_image_name(self):
assert ImageProvider._extract_registry("nginx") is None
+ def test_https_scheme_bare_hostname_returns_none(self):
+ """Bare scheme-prefixed hostname has no image path, so no registry is extracted."""
+ assert (
+ ImageProvider._extract_registry("https://my-registry.example.com") is None
+ )
+
+ def test_http_scheme_with_port_stripped(self):
+ assert (
+ ImageProvider._extract_registry("http://localhost:5000/myimage:latest")
+ == "localhost:5000"
+ )
+
+ def test_https_scheme_with_path_stripped(self):
+ assert (
+ ImageProvider._extract_registry("https://ghcr.io/org/image:tag")
+ == "ghcr.io"
+ )
+
class TestIsRegistryUrl:
def test_bare_ecr_hostname(self):
@@ -728,6 +785,16 @@ class TestIsRegistryUrl:
def test_dockerhub_namespace(self):
assert not ImageProvider._is_registry_url("library/alpine")
+ def test_https_scheme_bare_hostname(self):
+ assert ImageProvider._is_registry_url("https://my-registry.example.com")
+
+ def test_http_scheme_bare_hostname_with_port(self):
+ assert ImageProvider._is_registry_url("http://my-registry.example.com:5000")
+
+ def test_https_scheme_image_reference_not_registry(self):
+ """A scheme-prefixed full image reference is still an image, not a registry URL."""
+ assert not ImageProvider._is_registry_url("https://ghcr.io/myorg/repo:tag")
+
class TestTestRegistryConnection:
@patch("prowler.providers.image.image_provider.create_registry_adapter")
From 86449fb99d0b4ffdbce827e69965422e4e7d21d5 Mon Sep 17 00:00:00 2001
From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
Date: Mon, 4 May 2026 08:56:50 +0200
Subject: [PATCH 02/13] chore(vercel): add disclaimer for checks depending on
billing plan (#10663)
---
.../check-metadata-guidelines.mdx | 3 +
docs/developer-guide/checks.mdx | 2 +-
.../vercel/getting-started-vercel.mdx | 22 +++++
prowler/CHANGELOG.md | 1 +
prowler/lib/check/models.py | 28 +++---
prowler/providers/vercel/lib/billing.py | 27 ++++++
.../providers/vercel/lib/service/service.py | 6 +-
prowler/providers/vercel/models.py | 19 ++++
...thentication_no_stale_tokens.metadata.json | 3 +-
...entication_token_not_expired.metadata.json | 3 +-
...roduction_uses_stable_target.metadata.json | 3 +-
...main_dns_properly_configured.metadata.json | 3 +-
...domain_ssl_certificate_valid.metadata.json | 3 +-
.../domain_verified.metadata.json | 3 +-
...o_expose_system_env_disabled.metadata.json | 3 +-
...eployment_protection_enabled.metadata.json | 3 +-
...t_directory_listing_disabled.metadata.json | 3 +-
...nment_no_overly_broad_target.metadata.json | 3 +-
...ent_no_secrets_in_plain_type.metadata.json | 3 +-
...oduction_vars_not_in_preview.metadata.json | 3 +-
..._git_fork_protection_enabled.metadata.json | 3 +-
..._password_protection_enabled.metadata.json | 5 +-
.../project_password_protection_enabled.py | 2 +
...eployment_protection_enabled.metadata.json | 5 +-
...roduction_deployment_protection_enabled.py | 2 +
.../services/project/project_service.py | 11 ++-
...ject_skew_protection_enabled.metadata.json | 5 +-
.../project_skew_protection_enabled.py | 2 +
...rity_custom_rules_configured.metadata.json | 5 +-
.../security_custom_rules_configured.py | 12 ++-
...ip_blocking_rules_configured.metadata.json | 5 +-
.../security_ip_blocking_rules_configured.py | 12 ++-
...ity_managed_rulesets_enabled.metadata.json | 11 ++-
.../security_managed_rulesets_enabled.py | 13 ++-
...ity_rate_limiting_configured.metadata.json | 5 +-
.../security_rate_limiting_configured.py | 12 ++-
.../services/security/security_service.py | 12 ++-
.../security_waf_enabled.metadata.json | 5 +-
.../security_waf_enabled.py | 9 +-
.../team_directory_sync_enabled.metadata.json | 5 +-
.../team_directory_sync_enabled.py | 2 +
..._member_role_least_privilege.metadata.json | 3 +-
.../team_no_stale_invitations.metadata.json | 3 +-
.../team_saml_sso_enabled.metadata.json | 5 +-
.../team_saml_sso_enabled.py | 2 +
.../team_saml_sso_enforced.metadata.json | 5 +-
.../team_saml_sso_enforced.py | 2 +
.../vercel/services/team/team_service.py | 3 +
prowler/providers/vercel/vercel_provider.py | 5 +
tests/lib/check/models_test.py | 46 ++++++++-
.../vercel/lib/service/vercel_service_test.py | 29 ++++++
...roject_password_protection_enabled_test.py | 38 ++++++++
...tion_deployment_protection_enabled_test.py | 38 ++++++++
.../services/project/project_service_test.py | 67 +++++++++++++
.../project_skew_protection_enabled_test.py | 38 ++++++++
.../security_custom_rules_configured_test.py | 38 ++++++++
...urity_ip_blocking_rules_configured_test.py | 38 ++++++++
.../security_managed_rulesets_enabled_test.py | 42 +++++++-
.../security_rate_limiting_configured_test.py | 38 ++++++++
.../security/security_service_test.py | 8 +-
.../security_waf_enabled_test.py | 80 +++++++++++++++
.../team_directory_sync_enabled_test.py | 38 ++++++++
.../team_saml_sso_enabled_test.py | 39 ++++++++
.../team_saml_sso_enforced_test.py | 38 ++++++++
tests/providers/vercel/vercel_fixtures.py | 19 +++-
.../providers/vercel/vercel_metadata_test.py | 97 +++++++++++++++++++
66 files changed, 968 insertions(+), 78 deletions(-)
create mode 100644 prowler/providers/vercel/lib/billing.py
create mode 100644 tests/providers/vercel/lib/service/vercel_service_test.py
create mode 100644 tests/providers/vercel/vercel_metadata_test.py
diff --git a/docs/developer-guide/check-metadata-guidelines.mdx b/docs/developer-guide/check-metadata-guidelines.mdx
index bac16f6fc0..85b84620d5 100644
--- a/docs/developer-guide/check-metadata-guidelines.mdx
+++ b/docs/developer-guide/check-metadata-guidelines.mdx
@@ -215,3 +215,6 @@ Also is important to keep all code examples as short as possible, including the
| e5 | M365 and Azure Entra checks enabled by or dependent on an E5 license (e.g., advanced threat protection, audit, DLP, and eDiscovery) |
| privilege-escalation | Detects IAM policies or permissions that allow identities to elevate their privileges beyond their intended scope, potentially gaining administrator or higher-level access through specific action combinations |
| ec2-imdsv1 | Identifies EC2 instances using Instance Metadata Service version 1 (IMDSv1), which is vulnerable to SSRF attacks and should be replaced with IMDSv2 for enhanced security |
+| vercel-hobby-plan | Vercel checks whose audited feature is available on the Hobby plan (and therefore also on Pro and Enterprise plans) |
+| vercel-pro-plan | Vercel checks whose audited feature requires a Pro plan or higher, including features also available on Enterprise or via supported paid add-ons for Pro plans |
+| vercel-enterprise-plan | Vercel checks whose audited feature requires the Enterprise plan |
diff --git a/docs/developer-guide/checks.mdx b/docs/developer-guide/checks.mdx
index da469678fa..956dc293d1 100644
--- a/docs/developer-guide/checks.mdx
+++ b/docs/developer-guide/checks.mdx
@@ -387,7 +387,7 @@ Provides both code examples and best practice recommendations for addressing the
#### Categories
-One or more functional groupings used for execution filtering (e.g., `internet-exposed`). You can define new categories just by adding to this field.
+One or more functional groupings used for execution filtering (e.g., `internet-exposed`). Categories must match the predefined values enforced by `CheckMetadata`; adding a new category requires updating the validator and the metadata documentation.
For the complete list of available categories, see [Categories Guidelines](/developer-guide/check-metadata-guidelines#categories-guidelines).
diff --git a/docs/user-guide/providers/vercel/getting-started-vercel.mdx b/docs/user-guide/providers/vercel/getting-started-vercel.mdx
index ddec26e6dc..8a6fdecc22 100644
--- a/docs/user-guide/providers/vercel/getting-started-vercel.mdx
+++ b/docs/user-guide/providers/vercel/getting-started-vercel.mdx
@@ -160,3 +160,25 @@ Prowler for Vercel includes security checks across the following services:
| **Project** | Deployment protection, environment variable security, fork protection, and skew protection |
| **Security** | Web Application Firewall (WAF), rate limiting, IP blocking, and managed rulesets |
| **Team** | SSO enforcement, directory sync, member access, and invitation hygiene |
+
+## Checks With Explicit Plan-Based Behavior
+
+Prowler currently includes 26 Vercel checks. The 11 checks below have explicit billing-plan handling in the provider metadata or check logic. When the scanned scope reports a billing plan, Prowler adds plan-aware context to findings for these checks. If the API does not expose the required configuration, Prowler may return `MANUAL` and require verification in the Vercel dashboard.
+
+| Check ID | Hobby | Pro | Enterprise | Notes |
+|----------|-------|-----|------------|-------|
+| `project_password_protection_enabled` | Not available | Available as a paid add-on | Available | Checks password protection for deployments |
+| `project_production_deployment_protection_enabled` | Not available | Available with supported paid deployment protection options | Available | Checks protection for production deployments |
+| `project_skew_protection_enabled` | Not available | Available | Available | Checks skew protection during rollouts |
+| `security_custom_rules_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
+| `security_ip_blocking_rules_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
+| `team_saml_sso_enabled` | Not available | Available | Available | Checks team SAML SSO configuration |
+| `team_saml_sso_enforced` | Not available | Available | Available | Checks SAML SSO enforcement for all team members |
+| `team_directory_sync_enabled` | Not available | Not available | Available | Checks SCIM directory sync |
+| `security_managed_rulesets_enabled` | Bot Protection and AI Bots managed rulesets | Bot Protection and AI Bots managed rulesets | All managed rulesets, including OWASP Core Ruleset | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
+| `security_rate_limiting_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
+| `security_waf_enabled` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
+
+
+The five firewall-related checks (`security_waf_enabled`, `security_custom_rules_configured`, `security_ip_blocking_rules_configured`, `security_rate_limiting_configured`, and `security_managed_rulesets_enabled`) return `MANUAL` when the firewall configuration endpoint is not accessible from the API. The other 15 current Vercel checks do not currently include plan-specific handling in provider logic, but every Vercel check includes exactly one billing-plan metadata category (`vercel-hobby-plan`, `vercel-pro-plan`, or `vercel-enterprise-plan`) alongside its functional security category.
+
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index d49c7eb43c..643aeb518e 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844)
- Universal compliance pipeline integrated into the CLI: `--list-compliance` and `--list-compliance-requirements` show universal frameworks, and CSV plus OCSF outputs are generated for any framework declaring a `TableConfig` [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808)
+- Update Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
### 🔄 Changed
diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py
index dba664d298..f5f0ff87e8 100644
--- a/prowler/lib/check/models.py
+++ b/prowler/lib/check/models.py
@@ -62,6 +62,9 @@ VALID_CATEGORIES = frozenset(
"e5",
"privilege-escalation",
"ec2-imdsv1",
+ "vercel-hobby-plan",
+ "vercel-pro-plan",
+ "vercel-enterprise-plan",
}
)
@@ -244,14 +247,15 @@ class CheckMetadata(BaseModel):
# store the compliance later if supplied
Compliance: Optional[list[Any]] = Field(default_factory=list)
+ # TODO: Remove noqa and fix cls vulture errors
@validator("Categories", each_item=True, pre=True, always=True)
- def valid_category(cls, value, values):
+ def valid_category(cls, value, values): # noqa: F841
if not isinstance(value, str):
raise ValueError("Categories must be a list of strings")
value_lower = value.lower()
if not re.match("^[a-z0-9-]+$", value_lower):
raise ValueError(
- f"Invalid category: {value}. Categories can only contain lowercase letters, numbers and hyphen '-'"
+ f"Invalid category: {value}. Categories can only contain lowercase letters, numbers, and hyphen '-'"
)
if (
value_lower not in VALID_CATEGORIES
@@ -279,7 +283,7 @@ class CheckMetadata(BaseModel):
return resource_type
@validator("ServiceName", pre=True, always=True)
- def validate_service_name(cls, service_name, values):
+ def validate_service_name(cls, service_name, values): # noqa: F841
if not service_name:
raise ValueError("ServiceName must be a non-empty string")
@@ -296,7 +300,7 @@ class CheckMetadata(BaseModel):
return service_name
@validator("CheckID", pre=True, always=True)
- def valid_check_id(cls, check_id, values):
+ def valid_check_id(cls, check_id, values): # noqa: F841
if not check_id:
raise ValueError("CheckID must be a non-empty string")
@@ -309,7 +313,7 @@ class CheckMetadata(BaseModel):
return check_id
@validator("CheckTitle", pre=True, always=True)
- def validate_check_title(cls, check_title, values):
+ def validate_check_title(cls, check_title, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if len(check_title) > 150:
raise ValueError(
@@ -322,13 +326,13 @@ class CheckMetadata(BaseModel):
return check_title
@validator("RelatedUrl", pre=True, always=True)
- def validate_related_url(cls, related_url, values):
+ def validate_related_url(cls, related_url, values): # noqa: F841
if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
raise ValueError("RelatedUrl must be empty. This field is deprecated.")
return related_url
@validator("Remediation")
- def validate_recommendation_url(cls, remediation, values):
+ def validate_recommendation_url(cls, remediation, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
url = remediation.Recommendation.Url
if url and not url.startswith("https://hub.prowler.com/"):
@@ -338,7 +342,7 @@ class CheckMetadata(BaseModel):
return remediation
@validator("CheckType", pre=True, always=True)
- def validate_check_type(cls, check_type, values):
+ def validate_check_type(cls, check_type, values): # noqa: F841
provider = values.get("Provider", "").lower()
# Non-AWS providers must have an empty CheckType list
@@ -367,7 +371,7 @@ class CheckMetadata(BaseModel):
return check_type
@validator("Description", pre=True, always=True)
- def validate_description(cls, description, values):
+ def validate_description(cls, description, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if len(description) > 400:
raise ValueError(
@@ -376,7 +380,7 @@ class CheckMetadata(BaseModel):
return description
@validator("Risk", pre=True, always=True)
- def validate_risk(cls, risk, values):
+ def validate_risk(cls, risk, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if len(risk) > 400:
raise ValueError(
@@ -385,7 +389,7 @@ class CheckMetadata(BaseModel):
return risk
@validator("ResourceGroup", pre=True, always=True)
- def validate_resource_group(cls, resource_group):
+ def validate_resource_group(cls, resource_group): # noqa: F841
if resource_group and resource_group not in VALID_RESOURCE_GROUPS:
raise ValueError(
f"Invalid ResourceGroup: '{resource_group}'. Must be one of: {', '.join(sorted(VALID_RESOURCE_GROUPS))} or empty string."
@@ -393,7 +397,7 @@ class CheckMetadata(BaseModel):
return resource_group
@validator("AdditionalURLs", pre=True, always=True)
- def validate_additional_urls(cls, additional_urls):
+ def validate_additional_urls(cls, additional_urls): # noqa: F841
if not isinstance(additional_urls, list):
raise ValueError("AdditionalURLs must be a list")
diff --git a/prowler/providers/vercel/lib/billing.py b/prowler/providers/vercel/lib/billing.py
new file mode 100644
index 0000000000..4e7170dbe6
--- /dev/null
+++ b/prowler/providers/vercel/lib/billing.py
@@ -0,0 +1,27 @@
+from typing import Optional
+
+
+def extract_billing_plan(data: Optional[dict]) -> Optional[str]:
+ """Return the Vercel billing plan from a user or team payload.
+
+ Vercel's REST API consistently returns the plan identifier at
+ ``data["billing"]["plan"]`` (e.g. ``"hobby"``, ``"pro"``, ``"enterprise"``)
+ on both ``GET /v2/user`` and ``GET /v2/teams`` responses, even though the
+ field is not part of the public OpenAPI schema.
+ """
+ if not isinstance(data, dict):
+ return None
+ billing = data.get("billing")
+ if not isinstance(billing, dict):
+ return None
+ plan = billing.get("plan")
+ return plan.lower() if isinstance(plan, str) else None
+
+
+def plan_reason_suffix(
+ billing_plan: Optional[str], unsupported_plans: set[str], explanation: str
+) -> str:
+ """Return a plan-based explanation suffix only when the plan proves it."""
+ if billing_plan in unsupported_plans:
+ return f" This may be expected because {explanation}"
+ return ""
diff --git a/prowler/providers/vercel/lib/service/service.py b/prowler/providers/vercel/lib/service/service.py
index aaf4d2625c..7fd9ced264 100644
--- a/prowler/providers/vercel/lib/service/service.py
+++ b/prowler/providers/vercel/lib/service/service.py
@@ -84,10 +84,10 @@ class VercelService:
)
if response.status_code == 403:
- # Plan limitation or permission error — return None for graceful handling
- logger.warning(
+ # Endpoint unavailable for this token/scope; let checks handle it gracefully
+ logger.info(
f"{self.service} - Access denied for {path} (403). "
- "This may be a plan limitation."
+ "This may be caused by plan or permission restrictions."
)
return None
diff --git a/prowler/providers/vercel/models.py b/prowler/providers/vercel/models.py
index 5f0e207f19..5f730bde76 100644
--- a/prowler/providers/vercel/models.py
+++ b/prowler/providers/vercel/models.py
@@ -21,6 +21,7 @@ class VercelTeamInfo(BaseModel):
id: str
name: str
slug: str
+ billing_plan: Optional[str] = None
class VercelIdentityInfo(BaseModel):
@@ -29,9 +30,27 @@ class VercelIdentityInfo(BaseModel):
user_id: Optional[str] = None
username: Optional[str] = None
email: Optional[str] = None
+ billing_plan: Optional[str] = None
team: Optional[VercelTeamInfo] = None
teams: list[VercelTeamInfo] = Field(default_factory=list)
+ def get_billing_plan_for(self, scope_id: Optional[str]) -> Optional[str]:
+ """Return the billing plan for an explicit user or team scope."""
+ if not scope_id:
+ return None
+
+ if self.team and self.team.id == scope_id and self.team.billing_plan:
+ return self.team.billing_plan
+
+ for team in self.teams:
+ if team.id == scope_id:
+ return team.billing_plan
+
+ if self.user_id == scope_id:
+ return self.billing_plan
+
+ return None
+
class VercelOutputOptions(ProviderOutputOptions):
"""Customize output filenames for Vercel scans."""
diff --git a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json
index f4863ada5f..d2292216f3 100644
--- a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json
+++ b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
diff --git a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json
index 47e5087cf5..c3288d968d 100644
--- a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json
+++ b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
diff --git a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json
index 25416e6882..bb9b1a3b60 100644
--- a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json
+++ b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json
index e2450da8f1..28f151b2e6 100644
--- a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json
+++ b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
diff --git a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json
index ac683cd71f..ae8d2750a8 100644
--- a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json
+++ b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "encryption"
+ "encryption",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
diff --git a/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json
index 1e79a433ba..f5f1aace08 100644
--- a/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json
+++ b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
diff --git a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json
index 21c3f118e1..36e128caae 100644
--- a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json
+++ b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json
index 521610c617..55b5fc917c 100644
--- a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json
+++ b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
diff --git a/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json
index b477a5984f..a2558ed667 100644
--- a/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json
+++ b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json
index c9a418a503..5dc15b12aa 100644
--- a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json
+++ b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "secrets"
+ "secrets",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
diff --git a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json
index 90fea53eea..0e3e654f93 100644
--- a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json
+++ b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "secrets"
+ "secrets",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
diff --git a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json
index 6fc3e3af79..5b99fe00d6 100644
--- a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json
+++ b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "secrets"
+ "secrets",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
diff --git a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json
index 8e3db04fcd..37bbc7b8c6 100644
--- a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json
+++ b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json
index 2db77410d9..edd12c67f0 100644
--- a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json
+++ b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json
@@ -28,11 +28,12 @@
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-pro-plan"
],
"DependsOn": [],
"RelatedTo": [
"project_deployment_protection_enabled"
],
- "Notes": ""
+ "Notes": "Required billing plan: Enterprise, or as a paid add-on for Pro plans."
}
diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py
index 86c0778408..e5313c6182 100644
--- a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py
+++ b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.project.project_client import project_client
@@ -38,6 +39,7 @@ class project_password_protection_enabled(Check):
report.status_extended = (
f"Project {project.name} does not have password protection "
f"configured for deployments."
+ f"{plan_reason_suffix(project.billing_plan, {'hobby'}, 'password protection is not available on the Vercel Hobby plan.')}"
)
findings.append(report)
diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json
index 3760eefb57..20c1fac713 100644
--- a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json
+++ b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json
@@ -28,11 +28,12 @@
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-pro-plan"
],
"DependsOn": [],
"RelatedTo": [
"project_deployment_protection_enabled"
],
- "Notes": ""
+ "Notes": "Protecting production deployments requires Enterprise, or Pro plans with supported paid deployment protection options."
}
diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py
index ccbfd15325..bb0924266e 100644
--- a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py
+++ b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.project.project_client import project_client
@@ -38,6 +39,7 @@ class project_production_deployment_protection_enabled(Check):
report.status_extended = (
f"Project {project.name} does not have deployment protection "
f"enabled on production deployments."
+ f"{plan_reason_suffix(project.billing_plan, {'hobby'}, 'protecting production deployments is not available on the Vercel Hobby plan.')}"
)
findings.append(report)
diff --git a/prowler/providers/vercel/services/project/project_service.py b/prowler/providers/vercel/services/project/project_service.py
index e139a2a939..6cc3a36418 100644
--- a/prowler/providers/vercel/services/project/project_service.py
+++ b/prowler/providers/vercel/services/project/project_service.py
@@ -20,6 +20,7 @@ class Project(VercelService):
"""List all projects, optionally filtered by --project argument."""
try:
raw_projects = self._paginate("/v9/projects", "projects")
+ identity = getattr(self.provider, "identity", None)
filter_projects = self.provider.filter_projects
seen_ids: set[str] = set()
@@ -57,10 +58,17 @@ class Project(VercelService):
pwd_protection = proj.get("passwordProtection")
security = proj.get("security", {}) or {}
+ project_team_id = proj.get("accountId") or self.provider.session.team_id
+
self.projects[project_id] = VercelProject(
id=project_id,
name=project_name,
- team_id=proj.get("accountId") or self.provider.session.team_id,
+ team_id=project_team_id,
+ billing_plan=(
+ identity.get_billing_plan_for(project_team_id)
+ if identity
+ else None
+ ),
framework=proj.get("framework"),
node_version=proj.get("nodeVersion"),
auto_expose_system_envs=proj.get("autoExposeSystemEnvs", False),
@@ -160,6 +168,7 @@ class VercelProject(BaseModel):
id: str
name: str
team_id: Optional[str] = None
+ billing_plan: Optional[str] = None
framework: Optional[str] = None
node_version: Optional[str] = None
auto_expose_system_envs: bool = False
diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json
index a0a4f70cee..01e38c03c4 100644
--- a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json
+++ b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json
@@ -28,9 +28,10 @@
}
},
"Categories": [
- "resilience"
+ "resilience",
+ "vercel-pro-plan"
],
"DependsOn": [],
"RelatedTo": [],
- "Notes": ""
+ "Notes": "Required billing plan: Pro or Enterprise."
}
diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py
index f4e878ba5c..3f7a2d0ddb 100644
--- a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py
+++ b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.project.project_client import project_client
@@ -34,6 +35,7 @@ class project_skew_protection_enabled(Check):
report.status_extended = (
f"Project {project.name} does not have skew protection enabled, "
f"which may cause version mismatches during deployments."
+ f"{plan_reason_suffix(project.billing_plan, {'hobby'}, 'skew protection is not available on the Vercel Hobby plan.')}"
)
findings.append(report)
diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json
index a4b53baf4f..615f40843a 100644
--- a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json
+++ b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json
@@ -28,11 +28,12 @@
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-pro-plan"
],
"DependsOn": [],
"RelatedTo": [
"security_waf_enabled"
],
- "Notes": ""
+ "Notes": "Required billing plan: Pro or Enterprise."
}
diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py
index 6ec7f8834f..a525c93f0a 100644
--- a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py
+++ b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.security.security_client import security_client
@@ -24,7 +25,16 @@ class security_custom_rules_configured(Check):
for config in security_client.firewall_configs.values():
report = CheckReportVercel(metadata=self.metadata(), resource=config)
- if config.custom_rules:
+ if not config.firewall_config_accessible:
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Project {config.project_name} ({config.project_id}) "
+ f"could not be assessed for custom firewall rules because the "
+ f"firewall configuration endpoint was not accessible. "
+ f"Manual verification is required."
+ f"{plan_reason_suffix(config.billing_plan, {'hobby'}, 'custom firewall rules are not available on the Vercel Hobby plan.')}"
+ )
+ elif config.custom_rules:
report.status = "PASS"
report.status_extended = (
f"Project {config.project_name} ({config.project_id}) "
diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json
index a02cd35324..88c609e742 100644
--- a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json
+++ b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json
@@ -28,11 +28,12 @@
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-pro-plan"
],
"DependsOn": [],
"RelatedTo": [
"security_waf_enabled"
],
- "Notes": ""
+ "Notes": "Required billing plan: Pro or Enterprise."
}
diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py
index 0b89c8d111..443c052354 100644
--- a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py
+++ b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.security.security_client import security_client
@@ -25,7 +26,16 @@ class security_ip_blocking_rules_configured(Check):
for config in security_client.firewall_configs.values():
report = CheckReportVercel(metadata=self.metadata(), resource=config)
- if config.ip_blocking_rules:
+ if not config.firewall_config_accessible:
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Project {config.project_name} ({config.project_id}) "
+ f"could not be assessed for IP blocking rules because the "
+ f"firewall configuration endpoint was not accessible. "
+ f"Manual verification is required."
+ f"{plan_reason_suffix(config.billing_plan, {'hobby'}, 'IP blocking rules are not available on the Vercel Hobby plan.')}"
+ )
+ elif config.ip_blocking_rules:
report.status = "PASS"
report.status_extended = (
f"Project {config.project_name} ({config.project_id}) "
diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json
index ee66a3be21..cbd956cd52 100644
--- a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json
+++ b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json
@@ -9,7 +9,7 @@
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "security",
- "Description": "**Vercel projects** are assessed for **managed WAF ruleset** enablement. Managed rulesets are curated by Vercel and provide protection against known attack patterns including **OWASP Top 10** threats. This feature requires an Enterprise plan and reports MANUAL status when unavailable.",
+ "Description": "**Vercel projects** are assessed for **managed WAF ruleset** enablement. Managed rulesets are curated by Vercel and provide protection against known attack patterns including **OWASP Top 10** threats. Availability varies by ruleset, and the check reports MANUAL when the firewall configuration cannot be assessed from the API.",
"Risk": "Without **managed rulesets** enabled, the firewall lacks curated protection rules against well-known attack patterns. The application relies solely on custom rules, which may miss **new or evolving threats** that managed rulesets are designed to detect and block automatically.",
"RelatedUrl": "",
"AdditionalURLs": [
@@ -19,20 +19,21 @@
"Code": {
"CLI": "",
"NativeIaC": "",
- "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Enable managed rulesets from the available options\n4. Review and configure ruleset sensitivity levels\n5. Note: This feature requires an Enterprise plan",
+ "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Enable the managed rulesets that are available for your plan\n4. Review and configure ruleset sensitivity levels\n5. If the API does not expose firewall configuration for the project, verify the rulesets manually in the dashboard",
"Terraform": ""
},
"Recommendation": {
- "Text": "Enable managed WAF rulesets to benefit from Vercel-curated protection against common attack patterns. If you are on a plan that does not support managed rulesets, consider upgrading to the Enterprise plan for enhanced security features.",
+ "Text": "Enable the managed WAF rulesets that are available for your Vercel plan to benefit from curated protection against common attack patterns. If the API does not expose firewall configuration for the project, verify the rulesets manually in the dashboard.",
"Url": "https://hub.prowler.com/check/security_managed_rulesets_enabled"
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [
"security_waf_enabled"
],
- "Notes": "This check is plan-gated. If the Vercel API returns a 403 for managed rulesets, the check reports MANUAL status indicating that an Enterprise plan is required."
+ "Notes": "Managed ruleset availability varies by ruleset. OWASP Core Ruleset requires Enterprise, while Bot Protection and AI Bots managed rulesets are available on all plans."
}
diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py
index ead4623f4c..f7f476ccad 100644
--- a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py
+++ b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.security.security_client import security_client
@@ -17,8 +18,8 @@ class security_managed_rulesets_enabled(Check):
"""Execute the Vercel Managed Rulesets Enabled check.
Iterates over all firewall configurations and checks if managed
- rulesets are enabled. Reports MANUAL status when the feature is
- not available due to plan limitations.
+ rulesets are enabled. Reports MANUAL status when the firewall
+ configuration cannot be assessed from the API.
Returns:
List[CheckReportVercel]: A list of reports for each project.
@@ -27,12 +28,14 @@ class security_managed_rulesets_enabled(Check):
for config in security_client.firewall_configs.values():
report = CheckReportVercel(metadata=self.metadata(), resource=config)
- if config.managed_rulesets is None:
+ if not config.firewall_config_accessible:
report.status = "MANUAL"
report.status_extended = (
f"Project {config.project_name} ({config.project_id}) "
- f"could not be assessed for managed rulesets. "
- f"Enterprise plan required to access this feature."
+ f"could not be assessed for managed rulesets because the "
+ f"firewall configuration endpoint was not accessible. "
+ f"Manual verification is required."
+ f"{plan_reason_suffix(config.billing_plan, {'hobby', 'pro'}, 'some managed WAF rulesets, including the OWASP Core Ruleset, are only available on Vercel Enterprise plans.')}"
)
elif config.managed_rulesets:
report.status = "PASS"
diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json
index 8a804233a5..637502ab6f 100644
--- a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json
+++ b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json
@@ -28,11 +28,12 @@
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-pro-plan"
],
"DependsOn": [],
"RelatedTo": [
"security_waf_enabled"
],
- "Notes": ""
+ "Notes": "Required billing plan: Pro or Enterprise."
}
diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py
index 3e6419666c..6e37fd2779 100644
--- a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py
+++ b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.security.security_client import security_client
@@ -24,7 +25,16 @@ class security_rate_limiting_configured(Check):
for config in security_client.firewall_configs.values():
report = CheckReportVercel(metadata=self.metadata(), resource=config)
- if config.rate_limiting_rules:
+ if not config.firewall_config_accessible:
+ report.status = "MANUAL"
+ report.status_extended = (
+ f"Project {config.project_name} ({config.project_id}) "
+ f"could not be assessed for rate limiting rules because the "
+ f"firewall configuration endpoint was not accessible. "
+ f"Manual verification is required."
+ f"{plan_reason_suffix(config.billing_plan, {'hobby'}, 'rate limiting rules are not available on the Vercel Hobby plan.')}"
+ )
+ elif config.rate_limiting_rules:
report.status = "PASS"
report.status_extended = (
f"Project {config.project_name} ({config.project_id}) "
diff --git a/prowler/providers/vercel/services/security/security_service.py b/prowler/providers/vercel/services/security/security_service.py
index 3c79f5c2f5..b472dcc811 100644
--- a/prowler/providers/vercel/services/security/security_service.py
+++ b/prowler/providers/vercel/services/security/security_service.py
@@ -29,11 +29,13 @@ class Security(VercelService):
data = self._read_firewall_config(project)
if data is None:
- # 403 — plan limitation, store with managed_rulesets=None
+ # Firewall config endpoint unavailable for this project/token
self.firewall_configs[project.id] = VercelFirewallConfig(
project_id=project.id,
project_name=project.name,
team_id=project.team_id,
+ billing_plan=project.billing_plan,
+ firewall_config_accessible=False,
firewall_enabled=False,
managed_rulesets=None,
name=project.name,
@@ -49,6 +51,8 @@ class Security(VercelService):
project_id=project.id,
project_name=project.name,
team_id=project.team_id,
+ billing_plan=project.billing_plan,
+ firewall_config_accessible=True,
firewall_enabled=(
fallback_firewall_enabled
if fallback_firewall_enabled is not None
@@ -93,6 +97,8 @@ class Security(VercelService):
project_id=project.id,
project_name=project.name,
team_id=project.team_id,
+ billing_plan=project.billing_plan,
+ firewall_config_accessible=True,
firewall_enabled=firewall_enabled,
managed_rulesets=managed,
custom_rules=custom_rules,
@@ -246,8 +252,10 @@ class VercelFirewallConfig(BaseModel):
project_id: str
project_name: Optional[str] = None
team_id: Optional[str] = None
+ billing_plan: Optional[str] = None
+ firewall_config_accessible: bool = True
firewall_enabled: bool = False
- managed_rulesets: Optional[dict] = None # None means plan-gated (403)
+ managed_rulesets: Optional[dict] = None # None means config endpoint unavailable
custom_rules: list[dict] = Field(default_factory=list)
ip_blocking_rules: list[dict] = Field(default_factory=list)
rate_limiting_rules: list[dict] = Field(default_factory=list)
diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json
index 7598e7cccd..467edbc66c 100644
--- a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json
+++ b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json
@@ -28,12 +28,13 @@
}
},
"Categories": [
- "internet-exposed"
+ "internet-exposed",
+ "vercel-pro-plan"
],
"DependsOn": [],
"RelatedTo": [
"security_managed_rulesets_enabled",
"security_custom_rules_configured"
],
- "Notes": ""
+ "Notes": "Required billing plan: Pro or Enterprise."
}
diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py
index 82859a4f5c..9ab93b87ca 100644
--- a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py
+++ b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.security.security_client import security_client
@@ -24,13 +25,15 @@ class security_waf_enabled(Check):
for config in security_client.firewall_configs.values():
report = CheckReportVercel(metadata=self.metadata(), resource=config)
- if config.managed_rulesets is None:
- # 403 — plan limitation, cannot determine WAF status
+ if not config.firewall_config_accessible:
+ # Firewall config could not be retrieved for this project
report.status = "MANUAL"
report.status_extended = (
f"Project {config.project_name} ({config.project_id}) "
- f"could not be checked for WAF status due to plan limitations. "
+ f"could not be checked for WAF status because the firewall "
+ f"configuration endpoint was not accessible. "
f"Manual verification is required."
+ f"{plan_reason_suffix(config.billing_plan, {'hobby'}, 'the Web Application Firewall is not available on the Vercel Hobby plan.')}"
)
elif config.firewall_enabled:
report.status = "PASS"
diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json
index fda5c7b94d..ed0ce1470d 100644
--- a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json
+++ b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json
@@ -29,11 +29,12 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-enterprise-plan"
],
"DependsOn": [],
"RelatedTo": [
"team_saml_sso_enabled"
],
- "Notes": ""
+ "Notes": "Required billing plan: Enterprise."
}
diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py
index 101922b6ed..d185de4971 100644
--- a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py
+++ b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.team.team_client import team_client
@@ -40,6 +41,7 @@ class team_directory_sync_enabled(Check):
report.status_extended = (
f"Team {team.name} does not have directory sync (SCIM) enabled. "
f"User provisioning and deprovisioning must be managed manually."
+ f"{plan_reason_suffix(team.billing_plan, {'hobby', 'pro'}, 'directory sync (SCIM) is only available on Vercel Enterprise plans.')}"
)
findings.append(report)
diff --git a/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json
index 37abf769ef..d648769c1f 100644
--- a/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json
+++ b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json
index 16a1d942e1..606f9d863b 100644
--- a/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json
+++ b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json
@@ -28,7 +28,8 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-hobby-plan"
],
"DependsOn": [],
"RelatedTo": [],
diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json
index 21785bf482..fe66ed48bc 100644
--- a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json
+++ b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json
@@ -29,11 +29,12 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-pro-plan"
],
"DependsOn": [],
"RelatedTo": [
"team_saml_sso_enforced"
],
- "Notes": ""
+ "Notes": "Required billing plan: Pro or Enterprise."
}
diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py
index 8a979ec66d..38960efa8c 100644
--- a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py
+++ b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.team.team_client import team_client
@@ -38,6 +39,7 @@ class team_saml_sso_enabled(Check):
report.status = "FAIL"
report.status_extended = (
f"Team {team.name} does not have SAML SSO enabled."
+ f"{plan_reason_suffix(team.billing_plan, {'hobby'}, 'SAML SSO is not available on the Vercel Hobby plan.')}"
)
findings.append(report)
diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json
index feb43bc179..3e69d9da49 100644
--- a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json
+++ b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json
@@ -29,11 +29,12 @@
}
},
"Categories": [
- "trust-boundaries"
+ "trust-boundaries",
+ "vercel-pro-plan"
],
"DependsOn": [],
"RelatedTo": [
"team_saml_sso_enabled"
],
- "Notes": ""
+ "Notes": "Required billing plan: Pro or Enterprise."
}
diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py
index 746ebba387..564f0b5d46 100644
--- a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py
+++ b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py
@@ -1,6 +1,7 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportVercel
+from prowler.providers.vercel.lib.billing import plan_reason_suffix
from prowler.providers.vercel.services.team.team_client import team_client
@@ -43,6 +44,7 @@ class team_saml_sso_enforced(Check):
else:
report.status_extended = (
f"Team {team.name} does not have SAML SSO enforced."
+ f"{plan_reason_suffix(team.billing_plan, {'hobby'}, 'SAML SSO is not available on the Vercel Hobby plan.')}"
)
findings.append(report)
diff --git a/prowler/providers/vercel/services/team/team_service.py b/prowler/providers/vercel/services/team/team_service.py
index 916374ea87..7d8b119def 100644
--- a/prowler/providers/vercel/services/team/team_service.py
+++ b/prowler/providers/vercel/services/team/team_service.py
@@ -4,6 +4,7 @@ from typing import Optional
from pydantic import BaseModel, Field
from prowler.lib.logger import logger
+from prowler.providers.vercel.lib.billing import extract_billing_plan
from prowler.providers.vercel.lib.service.service import VercelService
@@ -67,6 +68,7 @@ class Team(VercelService):
id=team_data.get("id", team_id),
name=team_data.get("name", ""),
slug=team_data.get("slug", ""),
+ billing_plan=extract_billing_plan(team_data),
saml=saml_config,
directory_sync_enabled=dir_sync,
created_at=created_at,
@@ -151,6 +153,7 @@ class VercelTeam(BaseModel):
id: str
name: str
slug: str
+ billing_plan: Optional[str] = None
saml: Optional[SAMLConfig] = None
directory_sync_enabled: bool = False
members: list[VercelTeamMember] = Field(default_factory=list)
diff --git a/prowler/providers/vercel/vercel_provider.py b/prowler/providers/vercel/vercel_provider.py
index 731adef6ed..54ab4627a9 100644
--- a/prowler/providers/vercel/vercel_provider.py
+++ b/prowler/providers/vercel/vercel_provider.py
@@ -20,6 +20,7 @@ from prowler.providers.vercel.exceptions.exceptions import (
VercelRateLimitError,
VercelSessionError,
)
+from prowler.providers.vercel.lib.billing import extract_billing_plan
from prowler.providers.vercel.lib.mutelist.mutelist import VercelMutelist
from prowler.providers.vercel.models import (
VercelIdentityInfo,
@@ -195,6 +196,7 @@ class VercelProvider(Provider):
user_id = user_data.get("id")
username = user_data.get("username")
email = user_data.get("email")
+ billing_plan = extract_billing_plan(user_data)
# Get team info
team_info = None
@@ -214,6 +216,7 @@ class VercelProvider(Provider):
id=team_data.get("id", session.team_id),
name=team_data.get("name", ""),
slug=team_data.get("slug", ""),
+ billing_plan=extract_billing_plan(team_data),
)
all_teams = [team_info]
elif team_response.status_code in (404, 403):
@@ -239,6 +242,7 @@ class VercelProvider(Provider):
id=t.get("id", ""),
name=t.get("name", ""),
slug=t.get("slug", ""),
+ billing_plan=extract_billing_plan(t),
)
)
if all_teams:
@@ -253,6 +257,7 @@ class VercelProvider(Provider):
user_id=user_id,
username=username,
email=email,
+ billing_plan=billing_plan,
team=team_info,
teams=all_teams,
)
diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py
index 815479cdfa..71fd1f718b 100644
--- a/tests/lib/check/models_test.py
+++ b/tests/lib/check/models_test.py
@@ -377,6 +377,50 @@ class TestCheckMetadataValidators:
check_metadata = CheckMetadata(**valid_metadata)
assert check_metadata.Categories == ["encryption", "logging", "secrets"]
+ def test_valid_vercel_plan_categories_success(self):
+ """Test Vercel plan categories are accepted using hyphen-separated names."""
+ valid_metadata = {
+ "Provider": "vercel",
+ "CheckID": "test_check",
+ "CheckTitle": "Test Check",
+ "CheckType": [],
+ "ServiceName": "test",
+ "SubServiceName": "subtest",
+ "ResourceIdTemplate": "template",
+ "Severity": "high",
+ "ResourceType": "TestResource",
+ "Description": "Test description",
+ "Risk": "Test risk",
+ "RelatedUrl": "",
+ "Remediation": {
+ "Code": {
+ "CLI": "test command",
+ "NativeIaC": "test native",
+ "Other": "test other",
+ "Terraform": "test terraform",
+ },
+ "Recommendation": {
+ "Text": "test recommendation",
+ "Url": "https://hub.prowler.com/check/test_check",
+ },
+ },
+ "Categories": [
+ "vercel-hobby-plan",
+ "vercel-pro-plan",
+ "vercel-enterprise-plan",
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Test notes",
+ }
+
+ check_metadata = CheckMetadata(**valid_metadata)
+ assert check_metadata.Categories == [
+ "vercel-hobby-plan",
+ "vercel-pro-plan",
+ "vercel-enterprise-plan",
+ ]
+
def test_valid_category_failure_non_string(self):
"""Test valid category validation fails with non-string category"""
invalid_metadata = {
@@ -454,7 +498,7 @@ class TestCheckMetadataValidators:
with pytest.raises(ValidationError) as exc_info:
CheckMetadata(**invalid_metadata)
assert (
- "Categories can only contain lowercase letters, numbers and hyphen"
+ "Categories can only contain lowercase letters, numbers, and hyphen '-'"
in str(exc_info.value)
)
diff --git a/tests/providers/vercel/lib/service/vercel_service_test.py b/tests/providers/vercel/lib/service/vercel_service_test.py
new file mode 100644
index 0000000000..e267b84841
--- /dev/null
+++ b/tests/providers/vercel/lib/service/vercel_service_test.py
@@ -0,0 +1,29 @@
+from unittest import mock
+
+from prowler.providers.vercel.lib.service.service import VercelService
+
+
+class TestVercelService:
+ def test_get_returns_none_and_logs_info_on_expected_403(self):
+ service = VercelService.__new__(VercelService)
+ service.audit_config = {"max_retries": 0}
+ service.service = "security"
+ service._team_id = None
+ service._base_url = "https://api.vercel.com"
+
+ response = mock.MagicMock()
+ response.status_code = 403
+
+ service._http_session = mock.MagicMock()
+ service._http_session.get.return_value = response
+
+ with mock.patch(
+ "prowler.providers.vercel.lib.service.service.logger"
+ ) as logger_mock:
+ result = service._get("/v1/security/firewall/config/active")
+
+ assert result is None
+ logger_mock.info.assert_called_once_with(
+ "security - Access denied for /v1/security/firewall/config/active (403). "
+ "This may be caused by plan or permission restrictions."
+ )
diff --git a/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py b/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py
index 9de3eb874a..cd19a682c9 100644
--- a/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py
+++ b/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py
@@ -142,3 +142,41 @@ class Test_project_password_protection_enabled:
== f"Project {PROJECT_NAME} does not have password protection configured for deployments."
)
assert result[0].team_id == TEAM_ID
+
+ def test_no_password_protection_hobby_plan(self):
+ project_client = mock.MagicMock
+ project_client.projects = {
+ PROJECT_ID: VercelProject(
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ billing_plan="hobby",
+ password_protection=None,
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(billing_plan="hobby"),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client",
+ new=project_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import (
+ project_password_protection_enabled,
+ )
+
+ check = project_password_protection_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == PROJECT_ID
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} does not have password protection configured for deployments. This may be expected because password protection is not available on the Vercel Hobby plan."
+ )
+ assert result[0].team_id == TEAM_ID
diff --git a/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py b/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py
index ff2517fbd3..eb8e15ddff 100644
--- a/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py
+++ b/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py
@@ -149,3 +149,41 @@ class Test_project_production_deployment_protection_enabled:
== f"Project {PROJECT_NAME} does not have deployment protection enabled on production deployments."
)
assert result[0].team_id == TEAM_ID
+
+ def test_protection_null_hobby_plan(self):
+ project_client = mock.MagicMock
+ project_client.projects = {
+ PROJECT_ID: VercelProject(
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ billing_plan="hobby",
+ production_deployment_protection=None,
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(billing_plan="hobby"),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client",
+ new=project_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import (
+ project_production_deployment_protection_enabled,
+ )
+
+ check = project_production_deployment_protection_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == PROJECT_ID
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} does not have deployment protection enabled on production deployments. This may be expected because protecting production deployments is not available on the Vercel Hobby plan."
+ )
+ assert result[0].team_id == TEAM_ID
diff --git a/tests/providers/vercel/services/project/project_service_test.py b/tests/providers/vercel/services/project/project_service_test.py
index b82588ddd9..cad889905e 100644
--- a/tests/providers/vercel/services/project/project_service_test.py
+++ b/tests/providers/vercel/services/project/project_service_test.py
@@ -5,6 +5,7 @@ from tests.providers.vercel.vercel_fixtures import (
PROJECT_ID,
PROJECT_NAME,
TEAM_ID,
+ USER_ID,
set_mocked_vercel_provider,
)
@@ -43,3 +44,69 @@ class TestProjectService:
"ai_bots": {"active": False, "action": "deny"},
}
assert project.bot_id_enabled is True
+
+ def test_list_projects_uses_scoped_team_billing_plan(self):
+ service = Project.__new__(Project)
+ service.provider = set_mocked_vercel_provider(
+ billing_plan="enterprise",
+ team_billing_plan="hobby",
+ )
+ service.projects = {}
+ service._paginate = mock.MagicMock(
+ return_value=[
+ {
+ "id": PROJECT_ID,
+ "name": PROJECT_NAME,
+ "accountId": TEAM_ID,
+ }
+ ]
+ )
+
+ service._list_projects()
+
+ project = service.projects[PROJECT_ID]
+ assert project.billing_plan == "hobby"
+
+ def test_list_projects_uses_user_billing_plan_for_user_scoped_project(self):
+ service = Project.__new__(Project)
+ service.provider = set_mocked_vercel_provider(
+ billing_plan="enterprise",
+ team_billing_plan="hobby",
+ )
+ service.projects = {}
+ service._paginate = mock.MagicMock(
+ return_value=[
+ {
+ "id": PROJECT_ID,
+ "name": PROJECT_NAME,
+ "accountId": USER_ID,
+ }
+ ]
+ )
+
+ service._list_projects()
+
+ project = service.projects[PROJECT_ID]
+ assert project.billing_plan == "enterprise"
+
+ def test_list_projects_does_not_guess_billing_plan_without_scope(self):
+ service = Project.__new__(Project)
+ service.provider = set_mocked_vercel_provider(
+ billing_plan="enterprise",
+ team_billing_plan="hobby",
+ )
+ service.provider.session.team_id = None
+ service.projects = {}
+ service._paginate = mock.MagicMock(
+ return_value=[
+ {
+ "id": PROJECT_ID,
+ "name": PROJECT_NAME,
+ }
+ ]
+ )
+
+ service._list_projects()
+
+ project = service.projects[PROJECT_ID]
+ assert project.billing_plan is None
diff --git a/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py b/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py
index 19d1ce8885..38c02040d1 100644
--- a/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py
+++ b/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py
@@ -105,3 +105,41 @@ class Test_project_skew_protection_enabled:
== f"Project {PROJECT_NAME} does not have skew protection enabled, which may cause version mismatches during deployments."
)
assert result[0].team_id == TEAM_ID
+
+ def test_skew_protection_disabled_hobby_plan(self):
+ project_client = mock.MagicMock
+ project_client.projects = {
+ PROJECT_ID: VercelProject(
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ billing_plan="hobby",
+ skew_protection=False,
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(billing_plan="hobby"),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled.project_client",
+ new=project_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled import (
+ project_skew_protection_enabled,
+ )
+
+ check = project_skew_protection_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == PROJECT_ID
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} does not have skew protection enabled, which may cause version mismatches during deployments. This may be expected because skew protection is not available on the Vercel Hobby plan."
+ )
+ assert result[0].team_id == TEAM_ID
diff --git a/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py b/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py
index 72fc44f61e..3eb92c4a8b 100644
--- a/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py
+++ b/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py
@@ -111,3 +111,41 @@ class Test_security_custom_rules_configured:
== f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any custom firewall rules configured."
)
assert result[0].team_id == TEAM_ID
+
+ def test_custom_rules_status_unavailable_hobby_plan(self):
+ security_client = mock.MagicMock
+ security_client.firewall_configs = {
+ PROJECT_ID: VercelFirewallConfig(
+ project_id=PROJECT_ID,
+ project_name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ billing_plan="hobby",
+ firewall_config_accessible=False,
+ managed_rulesets=None,
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured.security_client",
+ new=security_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured import (
+ security_custom_rules_configured,
+ )
+
+ check = security_custom_rules_configured()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for custom firewall rules because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because custom firewall rules are not available on the Vercel Hobby plan."
+ )
diff --git a/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py b/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py
index 20e6224a24..9d8f537229 100644
--- a/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py
+++ b/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py
@@ -111,3 +111,41 @@ class Test_security_ip_blocking_rules_configured:
== f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any IP blocking rules configured."
)
assert result[0].team_id == TEAM_ID
+
+ def test_ip_rules_status_unavailable_hobby_plan(self):
+ security_client = mock.MagicMock
+ security_client.firewall_configs = {
+ PROJECT_ID: VercelFirewallConfig(
+ project_id=PROJECT_ID,
+ project_name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ billing_plan="hobby",
+ firewall_config_accessible=False,
+ managed_rulesets=None,
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured.security_client",
+ new=security_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured import (
+ security_ip_blocking_rules_configured,
+ )
+
+ check = security_ip_blocking_rules_configured()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for IP blocking rules because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because IP blocking rules are not available on the Vercel Hobby plan."
+ )
diff --git a/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py b/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py
index 2cc32c4e91..03cd387d45 100644
--- a/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py
+++ b/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py
@@ -121,6 +121,7 @@ class Test_security_managed_rulesets_enabled:
project_id=PROJECT_ID,
project_name=PROJECT_NAME,
team_id=TEAM_ID,
+ firewall_config_accessible=False,
firewall_enabled=False,
managed_rulesets=None,
id=PROJECT_ID,
@@ -150,6 +151,45 @@ class Test_security_managed_rulesets_enabled:
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
- == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for managed rulesets. Enterprise plan required to access this feature."
+ == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for managed rulesets because the firewall configuration endpoint was not accessible. Manual verification is required."
)
assert result[0].team_id == TEAM_ID
+
+ def test_managed_rulesets_plan_gated_non_enterprise_scope(self):
+ security_client = mock.MagicMock
+ security_client.firewall_configs = {
+ PROJECT_ID: VercelFirewallConfig(
+ project_id=PROJECT_ID,
+ project_name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ billing_plan="pro",
+ firewall_config_accessible=False,
+ firewall_enabled=False,
+ managed_rulesets=None,
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client",
+ new=security_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import (
+ security_managed_rulesets_enabled,
+ )
+
+ check = security_managed_rulesets_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for managed rulesets because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because some managed WAF rulesets, including the OWASP Core Ruleset, are only available on Vercel Enterprise plans."
+ )
diff --git a/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py b/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py
index 1c00a3c358..aab3e84d71 100644
--- a/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py
+++ b/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py
@@ -111,3 +111,41 @@ class Test_security_rate_limiting_configured:
== f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any rate limiting rules configured."
)
assert result[0].team_id == TEAM_ID
+
+ def test_rate_limiting_status_unavailable_hobby_plan(self):
+ security_client = mock.MagicMock
+ security_client.firewall_configs = {
+ PROJECT_ID: VercelFirewallConfig(
+ project_id=PROJECT_ID,
+ project_name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ billing_plan="hobby",
+ firewall_config_accessible=False,
+ managed_rulesets=None,
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured.security_client",
+ new=security_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured import (
+ security_rate_limiting_configured,
+ )
+
+ check = security_rate_limiting_configured()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for rate limiting rules because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because rate limiting rules are not available on the Vercel Hobby plan."
+ )
diff --git a/tests/providers/vercel/services/security/security_service_test.py b/tests/providers/vercel/services/security/security_service_test.py
index d0a690b1db..17c546db90 100644
--- a/tests/providers/vercel/services/security/security_service_test.py
+++ b/tests/providers/vercel/services/security/security_service_test.py
@@ -7,7 +7,12 @@ from tests.providers.vercel.vercel_fixtures import PROJECT_ID, PROJECT_NAME, TEA
class TestSecurityService:
def test_fetch_firewall_config_reads_active_version_and_normalizes_response(self):
- project = VercelProject(id=PROJECT_ID, name=PROJECT_NAME, team_id=TEAM_ID)
+ project = VercelProject(
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ billing_plan="pro",
+ )
service = Security.__new__(Security)
service.firewall_configs = {}
@@ -89,6 +94,7 @@ class TestSecurityService:
)
config = service.firewall_configs[PROJECT_ID]
+ assert config.billing_plan == "pro"
assert config.firewall_enabled is True
assert config.managed_rulesets == {"owasp": {"active": True, "action": "deny"}}
assert [rule["id"] for rule in config.custom_rules] == ["rule-custom"]
diff --git a/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py b/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py
index 1641868175..8df46dfec6 100644
--- a/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py
+++ b/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py
@@ -113,3 +113,83 @@ class Test_security_waf_enabled:
== f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have the Web Application Firewall enabled."
)
assert result[0].team_id == TEAM_ID
+
+ def test_waf_status_unavailable(self):
+ security_client = mock.MagicMock
+ security_client.firewall_configs = {
+ PROJECT_ID: VercelFirewallConfig(
+ project_id=PROJECT_ID,
+ project_name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ firewall_config_accessible=False,
+ firewall_enabled=False,
+ managed_rulesets=None,
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client",
+ new=security_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import (
+ security_waf_enabled,
+ )
+
+ check = security_waf_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == PROJECT_ID
+ assert result[0].resource_name == PROJECT_NAME
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be checked for WAF status because the firewall configuration endpoint was not accessible. Manual verification is required."
+ )
+ assert result[0].team_id == TEAM_ID
+
+ def test_waf_status_unavailable_hobby_plan(self):
+ security_client = mock.MagicMock
+ security_client.firewall_configs = {
+ PROJECT_ID: VercelFirewallConfig(
+ project_id=PROJECT_ID,
+ project_name=PROJECT_NAME,
+ team_id=TEAM_ID,
+ billing_plan="hobby",
+ firewall_config_accessible=False,
+ firewall_enabled=False,
+ managed_rulesets=None,
+ id=PROJECT_ID,
+ name=PROJECT_NAME,
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client",
+ new=security_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import (
+ security_waf_enabled,
+ )
+
+ check = security_waf_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].status == "MANUAL"
+ assert (
+ result[0].status_extended
+ == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be checked for WAF status because the firewall configuration endpoint was not accessible. Manual verification is required. This may be expected because the Web Application Firewall is not available on the Vercel Hobby plan."
+ )
diff --git a/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py b/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py
index 7f08db7fa1..b85e12ba3c 100644
--- a/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py
+++ b/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py
@@ -105,3 +105,41 @@ class Test_team_directory_sync_enabled:
== f"Team {TEAM_NAME} does not have directory sync (SCIM) enabled. User provisioning and deprovisioning must be managed manually."
)
assert result[0].team_id == ""
+
+ def test_directory_sync_disabled_pro_plan(self):
+ team_client = mock.MagicMock
+ team_client.teams = {
+ TEAM_ID: VercelTeam(
+ id=TEAM_ID,
+ name=TEAM_NAME,
+ slug=TEAM_SLUG,
+ directory_sync_enabled=False,
+ billing_plan="pro",
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled.team_client",
+ new=team_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled import (
+ team_directory_sync_enabled,
+ )
+
+ check = team_directory_sync_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == TEAM_ID
+ assert result[0].resource_name == TEAM_NAME
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Team {TEAM_NAME} does not have directory sync (SCIM) enabled. User provisioning and deprovisioning must be managed manually. This may be expected because directory sync (SCIM) is only available on Vercel Enterprise plans."
+ )
+ assert result[0].team_id == ""
diff --git a/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py b/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py
index b7f40dd653..b99b862c1f 100644
--- a/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py
+++ b/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py
@@ -106,3 +106,42 @@ class Test_team_saml_sso_enabled:
== f"Team {TEAM_NAME} does not have SAML SSO enabled."
)
assert result[0].team_id == ""
+
+ def test_saml_disabled_hobby_plan(self):
+ team_client = mock.MagicMock
+ team_client.teams = {
+ TEAM_ID: VercelTeam(
+ id=TEAM_ID,
+ name=TEAM_NAME,
+ slug=TEAM_SLUG,
+ saml=SAMLConfig(status="disabled", enforced=False),
+ billing_plan="hobby",
+ members=[],
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled.team_client",
+ new=team_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled import (
+ team_saml_sso_enabled,
+ )
+
+ check = team_saml_sso_enabled()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == TEAM_ID
+ assert result[0].resource_name == TEAM_NAME
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Team {TEAM_NAME} does not have SAML SSO enabled. This may be expected because SAML SSO is not available on the Vercel Hobby plan."
+ )
+ assert result[0].team_id == ""
diff --git a/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py b/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py
index 360d6cdbd8..839c42f3ad 100644
--- a/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py
+++ b/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py
@@ -142,3 +142,41 @@ class Test_team_saml_sso_enforced:
== f"Team {TEAM_NAME} does not have SAML SSO enforced."
)
assert result[0].team_id == ""
+
+ def test_saml_disabled_hobby_plan(self):
+ team_client = mock.MagicMock
+ team_client.teams = {
+ TEAM_ID: VercelTeam(
+ id=TEAM_ID,
+ name=TEAM_NAME,
+ slug=TEAM_SLUG,
+ saml=SAMLConfig(status="disabled", enforced=False),
+ billing_plan="hobby",
+ )
+ }
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_vercel_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client",
+ new=team_client,
+ ),
+ ):
+ from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import (
+ team_saml_sso_enforced,
+ )
+
+ check = team_saml_sso_enforced()
+ result = check.execute()
+ assert len(result) == 1
+ assert result[0].resource_id == TEAM_ID
+ assert result[0].resource_name == TEAM_NAME
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"Team {TEAM_NAME} does not have SAML SSO enforced. This may be expected because SAML SSO is not available on the Vercel Hobby plan."
+ )
+ assert result[0].team_id == ""
diff --git a/tests/providers/vercel/vercel_fixtures.py b/tests/providers/vercel/vercel_fixtures.py
index 70775bb377..3e5a21f4ef 100644
--- a/tests/providers/vercel/vercel_fixtures.py
+++ b/tests/providers/vercel/vercel_fixtures.py
@@ -33,6 +33,8 @@ def set_mocked_vercel_provider(
team_id: str = TEAM_ID,
identity: VercelIdentityInfo = None,
audit_config: dict = None,
+ billing_plan: str = None,
+ team_billing_plan: str = None,
):
"""Create a mocked VercelProvider for testing."""
provider = MagicMock()
@@ -42,15 +44,22 @@ def set_mocked_vercel_provider(
team_id=team_id,
http_session=MagicMock(),
)
+ resolved_team_billing_plan = (
+ team_billing_plan if team_billing_plan is not None else billing_plan
+ )
+ team_info = VercelTeamInfo(
+ id=TEAM_ID,
+ name=TEAM_NAME,
+ slug=TEAM_SLUG,
+ billing_plan=resolved_team_billing_plan,
+ )
provider.identity = identity or VercelIdentityInfo(
user_id=USER_ID,
username=USERNAME,
email=USER_EMAIL,
- team=VercelTeamInfo(
- id=TEAM_ID,
- name=TEAM_NAME,
- slug=TEAM_SLUG,
- ),
+ billing_plan=billing_plan,
+ team=team_info,
+ teams=[team_info],
)
provider.audit_config = audit_config or {"max_retries": 3}
provider.fixer_config = {}
diff --git a/tests/providers/vercel/vercel_metadata_test.py b/tests/providers/vercel/vercel_metadata_test.py
new file mode 100644
index 0000000000..0587c1a992
--- /dev/null
+++ b/tests/providers/vercel/vercel_metadata_test.py
@@ -0,0 +1,97 @@
+from prowler.lib.check.models import CheckMetadata
+
+
+class TestVercelMetadata:
+ EXPECTED_CATEGORIES = {
+ "authentication_no_stale_tokens": [
+ "trust-boundaries",
+ "vercel-hobby-plan",
+ ],
+ "authentication_token_not_expired": [
+ "trust-boundaries",
+ "vercel-hobby-plan",
+ ],
+ "deployment_production_uses_stable_target": [
+ "trust-boundaries",
+ "vercel-hobby-plan",
+ ],
+ "domain_dns_properly_configured": [
+ "trust-boundaries",
+ "vercel-hobby-plan",
+ ],
+ "domain_ssl_certificate_valid": ["encryption", "vercel-hobby-plan"],
+ "domain_verified": ["trust-boundaries", "vercel-hobby-plan"],
+ "project_auto_expose_system_env_disabled": [
+ "trust-boundaries",
+ "vercel-hobby-plan",
+ ],
+ "project_deployment_protection_enabled": [
+ "internet-exposed",
+ "vercel-hobby-plan",
+ ],
+ "project_directory_listing_disabled": [
+ "internet-exposed",
+ "vercel-hobby-plan",
+ ],
+ "project_environment_no_overly_broad_target": [
+ "secrets",
+ "vercel-hobby-plan",
+ ],
+ "project_environment_no_secrets_in_plain_type": [
+ "secrets",
+ "vercel-hobby-plan",
+ ],
+ "project_environment_production_vars_not_in_preview": [
+ "secrets",
+ "vercel-hobby-plan",
+ ],
+ "project_git_fork_protection_enabled": [
+ "internet-exposed",
+ "vercel-hobby-plan",
+ ],
+ "project_password_protection_enabled": [
+ "internet-exposed",
+ "vercel-pro-plan",
+ ],
+ "project_production_deployment_protection_enabled": [
+ "internet-exposed",
+ "vercel-pro-plan",
+ ],
+ "project_skew_protection_enabled": ["resilience", "vercel-pro-plan"],
+ "security_custom_rules_configured": [
+ "internet-exposed",
+ "vercel-pro-plan",
+ ],
+ "security_ip_blocking_rules_configured": [
+ "internet-exposed",
+ "vercel-pro-plan",
+ ],
+ "security_managed_rulesets_enabled": [
+ "internet-exposed",
+ "vercel-hobby-plan",
+ ],
+ "security_rate_limiting_configured": [
+ "internet-exposed",
+ "vercel-pro-plan",
+ ],
+ "security_waf_enabled": ["internet-exposed", "vercel-pro-plan"],
+ "team_directory_sync_enabled": [
+ "trust-boundaries",
+ "vercel-enterprise-plan",
+ ],
+ "team_member_role_least_privilege": [
+ "trust-boundaries",
+ "vercel-hobby-plan",
+ ],
+ "team_no_stale_invitations": ["trust-boundaries", "vercel-hobby-plan"],
+ "team_saml_sso_enabled": ["trust-boundaries", "vercel-pro-plan"],
+ "team_saml_sso_enforced": ["trust-boundaries", "vercel-pro-plan"],
+ }
+
+ def test_vercel_checks_use_legacy_and_plan_categories(self):
+ vercel_metadata = CheckMetadata.get_bulk(provider="vercel")
+
+ assert set(vercel_metadata) == set(self.EXPECTED_CATEGORIES)
+
+ for check_id, expected_categories in self.EXPECTED_CATEGORIES.items():
+ assert vercel_metadata[check_id].Categories == expected_categories
From 6cb770fcc8d7579dff689e07276752a0db3316c0 Mon Sep 17 00:00:00 2001
From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Date: Mon, 4 May 2026 11:17:54 +0100
Subject: [PATCH 03/13] fix(ui): clean up findings expanded resource row layout
(#10949)
---
ui/CHANGELOG.md | 8 ++
.../table/column-finding-resources.tsx | 78 +++++++++----------
.../table/inline-resource-container.tsx | 38 ++++-----
3 files changed, 62 insertions(+), 62 deletions(-)
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 8a18dae634..49ec9a26bd 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -2,6 +2,14 @@
All notable changes to the **Prowler UI** are documented in this file.
+## [1.26.0] (Prowler UNRELEASED)
+
+### 🔄 Changed
+
+- Findings expanded resource rows now drop the redundant cube icons, render Service and Region with the same compact label style as Last seen and Failing for, and reorder columns to Status, Resource, Provider, Severity, then field labels [(#10949)](https://github.com/prowler-cloud/prowler/pull/10949)
+
+---
+
## [1.25.1] (Prowler v5.25.1)
### 🐞 Fixed
diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx
index 4b184459ef..5789b8a96b 100644
--- a/ui/components/findings/table/column-finding-resources.tsx
+++ b/ui/components/findings/table/column-finding-resources.tsx
@@ -1,7 +1,7 @@
"use client";
import { ColumnDef, Row, RowSelectionState } from "@tanstack/react-table";
-import { Container, CornerDownRight, VolumeOff, VolumeX } from "lucide-react";
+import { CornerDownRight, VolumeOff, VolumeX } from "lucide-react";
import { useContext, useState } from "react";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
@@ -203,23 +203,6 @@ export function getColumnFindingResources({
enableSorting: false,
enableHiding: false,
},
- // Resource — name + uid (EntityInfo with resource icon)
- {
- id: "resource",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- }
- entityAlias={row.original.resourceName}
- entityId={row.original.resourceUid}
- />
-
- ),
- enableSorting: false,
- },
// Status
{
id: "status",
@@ -233,29 +216,35 @@ export function getColumnFindingResources({
},
enableSorting: false,
},
- // Service
+ // Resource — name + uid
{
- id: "service",
+ id: "resource",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
-
- {row.original.service}
-
+
+
+
),
enableSorting: false,
},
- // Region
+ // Provider — alias + uid (same style as Resource)
{
- id: "region",
+ id: "provider",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
-
- {row.original.region}
-
+
+
+
),
enableSorting: false,
},
@@ -268,20 +257,29 @@ export function getColumnFindingResources({
cell: ({ row }) => ,
enableSorting: false,
},
- // Account — alias + uid (EntityInfo with provider logo)
+ // Service
{
- id: "account",
+ id: "service",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
-
-
-
+
+ {row.original.service || "-"}
+
+ ),
+ enableSorting: false,
+ },
+ // Region
+ {
+ id: "region",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {row.original.region || "-"}
+
),
enableSorting: false,
},
diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx
index 2aa13056d9..22ccb027e1 100644
--- a/ui/components/findings/table/inline-resource-container.tsx
+++ b/ui/components/findings/table/inline-resource-container.tsx
@@ -70,27 +70,23 @@ function ResourceSkeletonRow({
- {/* Resource: icon + name + uid */}
-
-
-
{/* Status */}
- {/* Service */}
+ {/* Resource: name + uid */}
-
+
+
+
+
- {/* Region */}
+ {/* Provider: alias + uid */}
-
+
+
+
+
{/* Severity */}
@@ -99,15 +95,13 @@ function ResourceSkeletonRow({
- {/* Account: provider icon + alias + uid */}
+ {/* Service */}
-
+
+
+ {/* Region */}
+
+
{/* Last seen */}
From 921f49a0de407b06afc2d311564f56052526a724 Mon Sep 17 00:00:00 2001
From: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
Date: Mon, 4 May 2026 12:38:15 +0200
Subject: [PATCH 04/13] feat(aws): add bedrock_prompt_management_exists
security check (#10878)
---
prowler/CHANGELOG.md | 1 +
.../compliance/aws/kisa_isms_p_2023_aws.json | 1 +
.../aws/kisa_isms_p_2023_korean_aws.json | 1 +
.../__init__.py | 0
...ock_prompt_management_exists.metadata.json | 39 +++
.../bedrock_prompt_management_exists.py | 54 ++++
.../aws/services/bedrock/bedrock_service.py | 37 +++
.../bedrock_prompt_management_exists_test.py | 280 ++++++++++++++++++
.../services/bedrock/bedrock_service_test.py | 122 ++++++++
9 files changed, 535 insertions(+)
create mode 100644 prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/__init__.py
create mode 100644 prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.metadata.json
create mode 100644 prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.py
create mode 100644 tests/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists_test.py
diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md
index 643aeb518e..589f76323c 100644
--- a/prowler/CHANGELOG.md
+++ b/prowler/CHANGELOG.md
@@ -10,6 +10,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Universal compliance pipeline integrated into the CLI: `--list-compliance` and `--list-compliance-requirements` show universal frameworks, and CSV plus OCSF outputs are generated for any framework declaring a `TableConfig` [(#10301)](https://github.com/prowler-cloud/prowler/pull/10301)
- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808)
- Update Vercel checks to return personalized finding status extended depending on billing plan and classify them with billing-plan categories [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
+- `bedrock_prompt_management_exists` check for AWS provider [(#10878)](https://github.com/prowler-cloud/prowler/pull/10878)
### 🔄 Changed
diff --git a/prowler/compliance/aws/kisa_isms_p_2023_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_aws.json
index f7c2a0bc69..b2b71fa905 100644
--- a/prowler/compliance/aws/kisa_isms_p_2023_aws.json
+++ b/prowler/compliance/aws/kisa_isms_p_2023_aws.json
@@ -2897,6 +2897,7 @@
"bedrock_guardrails_configured",
"bedrock_model_invocation_logging_enabled",
"bedrock_model_invocation_logs_encryption_enabled",
+ "bedrock_prompt_management_exists",
"cloudformation_stack_outputs_find_secrets",
"cloudfront_distributions_custom_ssl_certificate",
"cloudfront_distributions_default_root_object",
diff --git a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json
index 877b46250c..a933fc8d27 100644
--- a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json
+++ b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json
@@ -2901,6 +2901,7 @@
"bedrock_guardrails_configured",
"bedrock_model_invocation_logging_enabled",
"bedrock_model_invocation_logs_encryption_enabled",
+ "bedrock_prompt_management_exists",
"cloudformation_stack_outputs_find_secrets",
"cloudfront_distributions_custom_ssl_certificate",
"cloudfront_distributions_default_root_object",
diff --git a/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.metadata.json
new file mode 100644
index 0000000000..195cefccd1
--- /dev/null
+++ b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.metadata.json
@@ -0,0 +1,39 @@
+{
+ "Provider": "aws",
+ "CheckID": "bedrock_prompt_management_exists",
+ "CheckTitle": "Amazon Bedrock Prompt Management prompts exist in the region",
+ "CheckType": [
+ "Software and Configuration Checks/AWS Security Best Practices"
+ ],
+ "ServiceName": "bedrock",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "low",
+ "ResourceType": "Other",
+ "ResourceGroup": "ai_ml",
+ "Description": "**Bedrock Prompt Management** enables centralized creation, versioning, and governance of prompts used with foundation models.\n\nThis region-level check verifies whether at least one managed prompt exists in each scanned region, used as an adoption signal for Prompt Management. The presence of a prompt does not by itself guarantee that every application prompt is managed.",
+ "Risk": "Without **Prompt Management**, prompts are scattered across applications with no central oversight, versioning, or auditability over instructions sent to foundation models, weakening governance and compliance posture.\n\nManaged prompts are a governance enabler; **prompt injection** defenses are provided by Bedrock **guardrails**, covered by separate checks.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management.html",
+ "https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-management-create.html"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "aws bedrock-agent create-prompt --name example_prompt --default-variant default --variants '[{\"name\":\"default\",\"templateType\":\"TEXT\",\"templateConfiguration\":{\"text\":{\"text\":\"Your prompt template here.\"}}}]'",
+ "NativeIaC": "",
+ "Other": "1. Open the Amazon Bedrock console\n2. Navigate to Prompt Management\n3. Click Create prompt\n4. Provide a name and configure the prompt template (a prompt can contain at most one variant; additional variants are created via CreatePromptVersion)\n5. Save the prompt",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Adopt **Bedrock Prompt Management** to centralize prompt definitions, enforce versioning, and maintain governance over model interactions.\n\nUse managed prompts with **guardrails** and apply **least privilege** access controls to restrict who can create or modify prompts.",
+ "Url": "https://hub.prowler.com/check/bedrock_prompt_management_exists"
+ }
+ },
+ "Categories": [
+ "gen-ai"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": "Results are generated per scanned region. Regions where `ListPrompts` cannot be queried are omitted from the findings."
+}
diff --git a/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.py b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.py
new file mode 100644
index 0000000000..b8ec65dbb4
--- /dev/null
+++ b/prowler/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists.py
@@ -0,0 +1,54 @@
+"""Check for region-level Bedrock Prompt Management adoption."""
+
+from prowler.lib.check.models import Check, Check_Report_AWS
+from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
+ bedrock_agent_client,
+)
+
+
+class bedrock_prompt_management_exists(Check):
+ """Check whether Amazon Bedrock Prompt Management prompts exist in the region.
+
+ A region is reported only when ListPrompts succeeded for it; regions where
+ the API call failed (e.g. AccessDenied, unsupported region) are skipped at
+ the service layer and produce no finding.
+
+ - PASS: At least one managed prompt exists in the region (one finding per prompt).
+ - FAIL: No managed prompts exist in the region (one finding per region).
+ """
+
+ def execute(self) -> list[Check_Report_AWS]:
+ """Execute the Bedrock Prompt Management exists check.
+
+ Returns:
+ A list of reports containing the result of the check.
+ """
+ findings = []
+ for region in sorted(bedrock_agent_client.prompt_scanned_regions):
+ regional_prompts = sorted(
+ (
+ prompt
+ for prompt in bedrock_agent_client.prompts.values()
+ if prompt.region == region
+ ),
+ key=lambda prompt: prompt.name,
+ )
+
+ if regional_prompts:
+ for prompt in regional_prompts:
+ report = Check_Report_AWS(metadata=self.metadata(), resource=prompt)
+ report.status = "PASS"
+ report.status_extended = f"Bedrock Prompt Management prompt {prompt.name} exists in region {region}."
+ findings.append(report)
+ else:
+ report = Check_Report_AWS(metadata=self.metadata(), resource={})
+ report.region = region
+ report.resource_id = "prompt-management"
+ report.resource_arn = f"arn:{bedrock_agent_client.audited_partition}:bedrock:{region}:{bedrock_agent_client.audited_account}:prompt-management"
+ report.status = "FAIL"
+ report.status_extended = (
+ f"No Bedrock Prompt Management prompts exist in region {region}."
+ )
+ findings.append(report)
+
+ return findings
diff --git a/prowler/providers/aws/services/bedrock/bedrock_service.py b/prowler/providers/aws/services/bedrock/bedrock_service.py
index c19bcb9166..e04319fc57 100644
--- a/prowler/providers/aws/services/bedrock/bedrock_service.py
+++ b/prowler/providers/aws/services/bedrock/bedrock_service.py
@@ -140,7 +140,10 @@ class BedrockAgent(AWSService):
# Call AWSService's __init__
super().__init__("bedrock-agent", provider)
self.agents = {}
+ self.prompts = {}
+ self.prompt_scanned_regions: set = set()
self.__threading_call__(self._list_agents)
+ self.__threading_call__(self._list_prompts)
self.__threading_call__(self._list_tags_for_resource, self.agents.values())
def _list_agents(self, regional_client):
@@ -167,7 +170,32 @@ class BedrockAgent(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
+ def _list_prompts(self, regional_client):
+ """List all prompts in a region.
+
+ Prompt Management is evaluated as a region-level adoption signal, so
+ prompt collection is intentionally not filtered by audit_resources.
+ """
+ logger.info("Bedrock Agent - Listing Prompts...")
+ try:
+ paginator = regional_client.get_paginator("list_prompts")
+ for page in paginator.paginate():
+ for prompt in page.get("promptSummaries", []):
+ prompt_arn = prompt.get("arn", "")
+ self.prompts[prompt_arn] = Prompt(
+ id=prompt.get("id", ""),
+ name=prompt.get("name", ""),
+ arn=prompt_arn,
+ region=regional_client.region,
+ )
+ self.prompt_scanned_regions.add(regional_client.region)
+ except Exception as error:
+ logger.error(
+ f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
+ )
+
def _list_tags_for_resource(self, resource):
+ """List tags for a Bedrock Agent resource."""
logger.info("Bedrock Agent - Listing Tags for Resource...")
try:
agent_tags = (
@@ -190,3 +218,12 @@ class Agent(BaseModel):
guardrail_id: Optional[str] = None
region: str
tags: Optional[list] = []
+
+
+class Prompt(BaseModel):
+ """Model representing a Bedrock Prompt Management prompt."""
+
+ id: str
+ name: str
+ arn: str
+ region: str
diff --git a/tests/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists_test.py b/tests/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists_test.py
new file mode 100644
index 0000000000..c29b67c064
--- /dev/null
+++ b/tests/providers/aws/services/bedrock/bedrock_prompt_management_exists/bedrock_prompt_management_exists_test.py
@@ -0,0 +1,280 @@
+from unittest import mock
+
+import botocore
+from botocore.exceptions import ClientError
+from moto import mock_aws
+
+from tests.providers.aws.utils import (
+ AWS_ACCOUNT_NUMBER,
+ AWS_REGION_EU_WEST_1,
+ AWS_REGION_US_EAST_1,
+ set_mocked_aws_provider,
+)
+
+make_api_call = botocore.client.BaseClient._make_api_call
+
+PROMPT_ARN = (
+ f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id"
+)
+
+
+def mock_make_api_call_list_prompts_access_denied(self, operation_name, kwarg):
+ """Mock API call where ListPrompts fails with AccessDeniedException."""
+ if operation_name == "ListPrompts":
+ raise ClientError(
+ {
+ "Error": {
+ "Code": "AccessDeniedException",
+ "Message": "User is not authorized to perform: bedrock:ListPrompts",
+ }
+ },
+ operation_name,
+ )
+ return make_api_call(self, operation_name, kwarg)
+
+
+def mock_make_api_call_with_prompts(self, operation_name, kwarg):
+ """Mock API call that returns prompts."""
+ if operation_name == "ListPrompts":
+ return {
+ "promptSummaries": [
+ {
+ "id": "test-prompt-id",
+ "name": "test-prompt",
+ "arn": PROMPT_ARN,
+ }
+ ]
+ }
+ return make_api_call(self, operation_name, kwarg)
+
+
+def mock_make_api_call_with_multiple_prompts(self, operation_name, kwarg):
+ """Mock API call that returns multiple prompts."""
+ if operation_name == "ListPrompts":
+ return {
+ "promptSummaries": [
+ {
+ "id": "test-prompt-id-1",
+ "name": "test-prompt-1",
+ "arn": f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id-1",
+ },
+ {
+ "id": "test-prompt-id-2",
+ "name": "test-prompt-2",
+ "arn": f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id-2",
+ },
+ {
+ "id": "test-prompt-id-3",
+ "name": "test-prompt-3",
+ "arn": f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/test-prompt-id-3",
+ },
+ ]
+ }
+ return make_api_call(self, operation_name, kwarg)
+
+
+def mock_make_api_call_no_prompts(self, operation_name, kwarg):
+ """Mock API call that returns no prompts."""
+ if operation_name == "ListPrompts":
+ return {"promptSummaries": []}
+ return make_api_call(self, operation_name, kwarg)
+
+
+class Test_bedrock_prompt_management_exists:
+ @mock.patch(
+ "botocore.client.BaseClient._make_api_call",
+ new=mock_make_api_call_no_prompts,
+ )
+ @mock_aws
+ def test_no_prompts(self):
+ """Test FAIL when no prompts exist in the region."""
+ from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
+
+ 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.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client",
+ new=BedrockAgent(aws_provider),
+ ),
+ ):
+ from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import (
+ bedrock_prompt_management_exists,
+ )
+
+ check = bedrock_prompt_management_exists()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert (
+ result[0].status_extended
+ == f"No Bedrock Prompt Management prompts exist in region {AWS_REGION_US_EAST_1}."
+ )
+ assert result[0].resource_id == "prompt-management"
+ assert result[0].region == AWS_REGION_US_EAST_1
+ assert (
+ result[0].resource_arn
+ == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt-management"
+ )
+
+ @mock.patch(
+ "botocore.client.BaseClient._make_api_call",
+ new=mock_make_api_call_with_prompts,
+ )
+ @mock_aws
+ def test_prompts_exist(self):
+ """Test PASS when prompts exist in the region."""
+ from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
+
+ 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.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client",
+ new=BedrockAgent(aws_provider),
+ ),
+ ):
+ from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import (
+ bedrock_prompt_management_exists,
+ )
+
+ check = bedrock_prompt_management_exists()
+ result = check.execute()
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert (
+ result[0].status_extended
+ == f"Bedrock Prompt Management prompt test-prompt exists in region {AWS_REGION_US_EAST_1}."
+ )
+ assert result[0].resource_id == "test-prompt-id"
+ assert result[0].region == AWS_REGION_US_EAST_1
+ assert result[0].resource_arn == PROMPT_ARN
+
+ @mock.patch(
+ "botocore.client.BaseClient._make_api_call",
+ new=mock_make_api_call_with_multiple_prompts,
+ )
+ @mock_aws
+ def test_multiple_prompts_exist(self):
+ """Test PASS with one finding per prompt when multiple prompts exist."""
+ from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
+
+ 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.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client",
+ new=BedrockAgent(aws_provider),
+ ),
+ ):
+ from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import (
+ bedrock_prompt_management_exists,
+ )
+
+ check = bedrock_prompt_management_exists()
+ result = check.execute()
+
+ assert len(result) == 3
+ for index, finding in enumerate(result, start=1):
+ expected_name = f"test-prompt-{index}"
+ expected_id = f"test-prompt-id-{index}"
+ assert finding.status == "PASS"
+ assert (
+ finding.status_extended
+ == f"Bedrock Prompt Management prompt {expected_name} exists in region {AWS_REGION_US_EAST_1}."
+ )
+ assert finding.resource_id == expected_id
+ assert finding.region == AWS_REGION_US_EAST_1
+ assert (
+ finding.resource_arn
+ == f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:prompt/{expected_id}"
+ )
+
+ @mock.patch(
+ "botocore.client.BaseClient._make_api_call",
+ new=mock_make_api_call_no_prompts,
+ )
+ @mock_aws
+ def test_no_prompts_multiple_regions(self):
+ """Test FAIL in multiple regions when no prompts exist."""
+ from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
+
+ aws_provider = set_mocked_aws_provider(
+ [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1]
+ )
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=aws_provider,
+ ),
+ mock.patch(
+ "prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client",
+ new=BedrockAgent(aws_provider),
+ ),
+ ):
+ from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import (
+ bedrock_prompt_management_exists,
+ )
+
+ check = bedrock_prompt_management_exists()
+ result = check.execute()
+
+ assert len(result) == 2
+ for finding in result:
+ assert finding.status == "FAIL"
+ assert (
+ finding.status_extended
+ == f"No Bedrock Prompt Management prompts exist in region {finding.region}."
+ )
+ assert finding.resource_id == "prompt-management"
+ assert (
+ finding.resource_arn
+ == f"arn:aws:bedrock:{finding.region}:{AWS_ACCOUNT_NUMBER}:prompt-management"
+ )
+ regions = {finding.region for finding in result}
+ assert regions == {AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1}
+
+ @mock.patch(
+ "botocore.client.BaseClient._make_api_call",
+ new=mock_make_api_call_list_prompts_access_denied,
+ )
+ @mock_aws
+ def test_list_prompts_client_error_skips_region(self):
+ """Test that regions where ListPrompts fails produce no findings."""
+ from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent
+
+ 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.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists.bedrock_agent_client",
+ new=BedrockAgent(aws_provider),
+ ),
+ ):
+ from prowler.providers.aws.services.bedrock.bedrock_prompt_management_exists.bedrock_prompt_management_exists import (
+ bedrock_prompt_management_exists,
+ )
+
+ check = bedrock_prompt_management_exists()
+ result = check.execute()
+
+ assert result == []
diff --git a/tests/providers/aws/services/bedrock/bedrock_service_test.py b/tests/providers/aws/services/bedrock/bedrock_service_test.py
index ed39ac865b..5f7ddf0cf2 100644
--- a/tests/providers/aws/services/bedrock/bedrock_service_test.py
+++ b/tests/providers/aws/services/bedrock/bedrock_service_test.py
@@ -341,3 +341,125 @@ class TestBedrockAgentPagination:
# Verify paginator was used
regional_client.get_paginator.assert_called_once_with("list_agents")
paginator.paginate.assert_called_once()
+
+
+class TestBedrockPromptPagination:
+ """Test suite for Bedrock Prompt pagination logic."""
+
+ def test_list_prompts_pagination(self):
+ """Test that list_prompts iterates through all pages."""
+ # Mock the audit_info
+ audit_info = MagicMock()
+ audit_info.audited_partition = "aws"
+ audit_info.audited_account = "123456789012"
+ audit_info.audit_resources = None
+
+ # Mock the regional client
+ regional_client = MagicMock()
+ regional_client.region = "us-east-1"
+
+ # Mock paginator
+ paginator = MagicMock()
+ page1 = {
+ "promptSummaries": [
+ {
+ "id": "prompt-1",
+ "name": "prompt-name-1",
+ "arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1",
+ }
+ ]
+ }
+ page2 = {
+ "promptSummaries": [
+ {
+ "id": "prompt-2",
+ "name": "prompt-name-2",
+ "arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2",
+ }
+ ]
+ }
+ paginator.paginate.return_value = [page1, page2]
+ regional_client.get_paginator.return_value = paginator
+
+ # Initialize service and inject mock client
+ bedrock_agent_service = BedrockAgent(audit_info)
+ bedrock_agent_service.regional_clients = {"us-east-1": regional_client}
+ bedrock_agent_service.prompts = {} # Clear init side effects
+ bedrock_agent_service.prompt_scanned_regions = set()
+
+ # Run method
+ bedrock_agent_service._list_prompts(regional_client)
+
+ # Assertions
+ assert len(bedrock_agent_service.prompts) == 2
+ assert (
+ "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1"
+ in bedrock_agent_service.prompts
+ )
+ assert (
+ "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-2"
+ in bedrock_agent_service.prompts
+ )
+ assert "us-east-1" in bedrock_agent_service.prompt_scanned_regions
+
+ # Verify paginator was used
+ regional_client.get_paginator.assert_called_once_with("list_prompts")
+ paginator.paginate.assert_called_once()
+
+ def test_list_prompts_ignores_audit_resources_filter(self):
+ """Prompt collection is region-scoped and must ignore audit_resources."""
+ audit_info = MagicMock()
+ audit_info.audited_partition = "aws"
+ audit_info.audited_account = "123456789012"
+ audit_info.audit_resources = ["arn:aws:s3:::unrelated-resource"]
+
+ regional_client = MagicMock()
+ regional_client.region = "us-east-1"
+
+ paginator = MagicMock()
+ paginator.paginate.return_value = [
+ {
+ "promptSummaries": [
+ {
+ "id": "prompt-1",
+ "name": "prompt-name-1",
+ "arn": "arn:aws:bedrock:us-east-1:123456789012:prompt/prompt-1",
+ }
+ ]
+ }
+ ]
+ regional_client.get_paginator.return_value = paginator
+
+ bedrock_agent_service = BedrockAgent(audit_info)
+ bedrock_agent_service.regional_clients = {"us-east-1": regional_client}
+ bedrock_agent_service.prompts = {}
+ bedrock_agent_service.prompt_scanned_regions = set()
+
+ bedrock_agent_service._list_prompts(regional_client)
+
+ assert len(bedrock_agent_service.prompts) == 1
+ assert "us-east-1" in bedrock_agent_service.prompt_scanned_regions
+
+ def test_list_prompts_error_does_not_mark_region_scanned(self):
+ """If ListPrompts raises, the region must not be added to prompt_scanned_regions."""
+ audit_info = MagicMock()
+ audit_info.audited_partition = "aws"
+ audit_info.audited_account = "123456789012"
+ audit_info.audit_resources = None
+
+ regional_client = MagicMock()
+ regional_client.region = "us-east-1"
+
+ paginator = MagicMock()
+ paginator.paginate.side_effect = Exception("ListPrompts failed")
+ regional_client.get_paginator.return_value = paginator
+
+ bedrock_agent_service = BedrockAgent(audit_info)
+ bedrock_agent_service.regional_clients = {"us-east-1": regional_client}
+ bedrock_agent_service.prompts = {}
+ bedrock_agent_service.prompt_scanned_regions = set()
+
+ bedrock_agent_service._list_prompts(regional_client)
+
+ assert bedrock_agent_service.prompts == {}
+ assert bedrock_agent_service.prompt_scanned_regions == set()
From 652cb69216438e4357df88edd2a29a830535ae20 Mon Sep 17 00:00:00 2001
From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
Date: Mon, 4 May 2026 12:59:06 +0100
Subject: [PATCH 05/13] fix(ui): compliance card layout polish (#10939)
---
ui/CHANGELOG.md | 3 +-
ui/components/compliance/compliance-card.tsx | 80 ++++++++++----------
ui/components/icons/compliance/iso-27001.svg | 6 +-
3 files changed, 46 insertions(+), 43 deletions(-)
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index 49ec9a26bd..4b63c9cd84 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -2,10 +2,11 @@
All notable changes to the **Prowler UI** are documented in this file.
-## [1.26.0] (Prowler UNRELEASED)
+## [1.25.2] (Prowler UNRELEASED)
### 🔄 Changed
+- Compliance cards: progress bar now spans the full card width, the passing-requirements caption sits beside the framework logo under the title, and the ISO 27001 logo asset is recentered within its tile [(#10939)](https://github.com/prowler-cloud/prowler/pull/10939)
- Findings expanded resource rows now drop the redundant cube icons, render Service and Region with the same compact label style as Last seen and Failing for, and reorder columns to Status, Resource, Provider, Severity, then field labels [(#10949)](https://github.com/prowler-cloud/prowler/pull/10949)
---
diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx
index c94b7ef63e..98d3949016 100644
--- a/ui/components/compliance/compliance-card.tsx
+++ b/ui/components/compliance/compliance-card.tsx
@@ -119,49 +119,33 @@ export const ComplianceCard: React.FC = ({
/>
-
-
+
+
{getComplianceIcon(title) && (
-
+
+
+
)}
-
-
-
-
-
+
+
+
+
+ {formatTitle(title)}
+ {version ? ` - ${version}` : ""}
+
+
+
{formatTitle(title)}
{version ? ` - ${version}` : ""}
-
-
-
- {formatTitle(title)}
- {version ? ` - ${version}` : ""}
-
-
-
-
-
- Score:
-
-
- {ratingPercentage}%
-
-
-
-
-
-
+
+
+
{passingRequirements} / {totalRequirements}
@@ -169,6 +153,24 @@ export const ComplianceCard: React.FC = ({
+
+
+
+ Score:
+
+
+ {ratingPercentage}%
+
+
+
+
diff --git a/ui/components/icons/compliance/iso-27001.svg b/ui/components/icons/compliance/iso-27001.svg
index 109c19521d..b79f970b42 100644
--- a/ui/components/icons/compliance/iso-27001.svg
+++ b/ui/components/icons/compliance/iso-27001.svg
@@ -1,8 +1,8 @@
-