mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-14 16:50:04 +00:00
Compare commits
5 Commits
dependabot
...
PROWLER-13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f124038390 | ||
|
|
af3284afff | ||
|
|
ff7e8133e9 | ||
|
|
04a6dca608 | ||
|
|
18160e615f |
27
poetry.lock
generated
27
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -1876,6 +1876,7 @@ files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "contextlib2"
|
||||
@@ -3044,7 +3045,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.7.1"
|
||||
|
||||
@@ -3124,7 +3125,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.05.14"
|
||||
certifi = ">=14.5.14"
|
||||
durationpy = ">=0.7"
|
||||
google-auth = ">=1.0.1"
|
||||
oauthlib = ">=3.2.2"
|
||||
@@ -4934,7 +4935,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
astroid = ">=3.3.8,<=3.4.0-dev0"
|
||||
astroid = ">=3.3.8,<=3.4.0.dev0"
|
||||
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
|
||||
dill = [
|
||||
{version = ">=0.2", markers = "python_version < \"3.11\""},
|
||||
@@ -5390,25 +5391,25 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
version = "2.32.4"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"},
|
||||
{file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"},
|
||||
{file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"},
|
||||
{file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=2023.5.7"
|
||||
certifi = ">=2017.4.17"
|
||||
charset_normalizer = ">=2,<4"
|
||||
idna = ">=2.5,<4"
|
||||
urllib3 = ">=1.26,<3"
|
||||
urllib3 = ">=1.21.1,<3"
|
||||
|
||||
[package.extras]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
|
||||
[[package]]
|
||||
name = "requests-file"
|
||||
@@ -5780,10 +5781,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "safety"
|
||||
|
||||
@@ -22,6 +22,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
- Bump Poetry to `2.3.4` and consolidate SDK workflows onto the `setup-python-poetry` composite action with opt-in lockfile regeneration [(#10681)](https://github.com/prowler-cloud/prowler/pull/10681)
|
||||
- Normalize Conditional Access platform values in Entra models and simplify platform-based checks [(#10635)](https://github.com/prowler-cloud/prowler/pull/10635)
|
||||
- Update Vercel checks to return personalized finding status extended depending on billing plan [(#10663)](https://github.com/prowler-cloud/prowler/pull/10663)
|
||||
|
||||
---
|
||||
|
||||
|
||||
59
prowler/providers/vercel/lib/billing.py
Normal file
59
prowler/providers/vercel/lib/billing.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from typing import Optional
|
||||
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
|
||||
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 resolve_scope_billing_plan(scope_id: Optional[str] = None) -> Optional[str]:
|
||||
"""Resolve the billing plan for a Vercel scope ID, if it is available."""
|
||||
provider = Provider.get_global_provider()
|
||||
identity = getattr(provider, "identity", None)
|
||||
if not identity:
|
||||
return None
|
||||
|
||||
if scope_id:
|
||||
if (
|
||||
identity.team
|
||||
and identity.team.id == scope_id
|
||||
and identity.team.billing_plan
|
||||
):
|
||||
return identity.team.billing_plan
|
||||
|
||||
for team in identity.teams:
|
||||
if team.id == scope_id and team.billing_plan:
|
||||
return team.billing_plan
|
||||
|
||||
if identity.user_id == scope_id:
|
||||
return identity.billing_plan
|
||||
|
||||
return None
|
||||
|
||||
if identity.team and identity.team.billing_plan:
|
||||
return identity.team.billing_plan
|
||||
|
||||
return identity.billing_plan
|
||||
|
||||
|
||||
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 ""
|
||||
@@ -84,10 +84,10 @@ class VercelService:
|
||||
)
|
||||
|
||||
if response.status_code == 403:
|
||||
# Plan limitation or permission error — return None for graceful handling
|
||||
# Endpoint unavailable for this token/scope; let checks handle it gracefully
|
||||
logger.warning(
|
||||
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
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ class VercelTeamInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
billing_plan: Optional[str] = None
|
||||
|
||||
|
||||
class VercelIdentityInfo(BaseModel):
|
||||
@@ -29,6 +30,7 @@ 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)
|
||||
|
||||
|
||||
@@ -34,5 +34,5 @@
|
||||
"RelatedTo": [
|
||||
"project_deployment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
"Notes": "Required billing plan: Enterprise, or as a paid add-on for Pro plans."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.lib.billing import (
|
||||
plan_reason_suffix,
|
||||
resolve_scope_billing_plan,
|
||||
)
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
@@ -35,9 +39,11 @@ class project_password_protection_enabled(Check):
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
billing_plan = resolve_scope_billing_plan(project.team_id)
|
||||
report.status_extended = (
|
||||
f"Project {project.name} does not have password protection "
|
||||
f"configured for deployments."
|
||||
f"{plan_reason_suffix(billing_plan, {'hobby'}, 'password protection is not available on the Vercel Hobby plan.')}"
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
@@ -34,5 +34,5 @@
|
||||
"RelatedTo": [
|
||||
"project_deployment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
"Notes": "Protecting production deployments requires Enterprise, or Pro plans with supported paid deployment protection options."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.lib.billing import (
|
||||
plan_reason_suffix,
|
||||
resolve_scope_billing_plan,
|
||||
)
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
@@ -35,9 +39,11 @@ class project_production_deployment_protection_enabled(Check):
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
billing_plan = resolve_scope_billing_plan(project.team_id)
|
||||
report.status_extended = (
|
||||
f"Project {project.name} does not have deployment protection "
|
||||
f"enabled on production deployments."
|
||||
f"{plan_reason_suffix(billing_plan, {'hobby'}, 'protecting production deployments is not available on the Vercel Hobby plan.')}"
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
@@ -32,5 +32,5 @@
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
"Notes": "Required billing plan: Pro or Enterprise."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.lib.billing import (
|
||||
plan_reason_suffix,
|
||||
resolve_scope_billing_plan,
|
||||
)
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
@@ -31,9 +35,11 @@ class project_skew_protection_enabled(Check):
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
billing_plan = resolve_scope_billing_plan(project.team_id)
|
||||
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(billing_plan, {'hobby'}, 'skew protection is not available on the Vercel Hobby plan.')}"
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
@@ -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,11 +19,11 @@
|
||||
"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"
|
||||
}
|
||||
},
|
||||
@@ -34,5 +34,5 @@
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -17,8 +17,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.
|
||||
@@ -31,8 +31,9 @@ class security_managed_rulesets_enabled(Check):
|
||||
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."
|
||||
)
|
||||
elif config.managed_rulesets:
|
||||
report.status = "PASS"
|
||||
|
||||
@@ -32,7 +32,7 @@ class Security(VercelService):
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -118,7 +118,7 @@ class VercelFirewallConfig(BaseModel):
|
||||
project_name: Optional[str] = None
|
||||
team_id: Optional[str] = None
|
||||
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)
|
||||
|
||||
@@ -25,11 +25,12 @@ class security_waf_enabled(Check):
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=config)
|
||||
|
||||
if config.managed_rulesets is None:
|
||||
# 403 — plan limitation, cannot determine WAF status
|
||||
# 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."
|
||||
)
|
||||
elif config.firewall_enabled:
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"RelatedTo": [
|
||||
"team_saml_sso_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
"Notes": "Required billing plan: Enterprise."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"RelatedTo": [
|
||||
"team_saml_sso_enforced"
|
||||
],
|
||||
"Notes": ""
|
||||
"Notes": "Required billing plan: Pro or Enterprise."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"RelatedTo": [
|
||||
"team_saml_sso_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
"Notes": "Required billing plan: Pro or Enterprise."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -142,3 +142,40 @@ 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,
|
||||
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
|
||||
|
||||
@@ -149,3 +149,40 @@ 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,
|
||||
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
|
||||
|
||||
@@ -105,3 +105,40 @@ 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,
|
||||
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
|
||||
|
||||
@@ -150,6 +150,6 @@ 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
|
||||
|
||||
@@ -113,3 +113,43 @@ 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_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
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -33,6 +33,7 @@ def set_mocked_vercel_provider(
|
||||
team_id: str = TEAM_ID,
|
||||
identity: VercelIdentityInfo = None,
|
||||
audit_config: dict = None,
|
||||
billing_plan: str = None,
|
||||
):
|
||||
"""Create a mocked VercelProvider for testing."""
|
||||
provider = MagicMock()
|
||||
@@ -46,10 +47,12 @@ def set_mocked_vercel_provider(
|
||||
user_id=USER_ID,
|
||||
username=USERNAME,
|
||||
email=USER_EMAIL,
|
||||
billing_plan=billing_plan,
|
||||
team=VercelTeamInfo(
|
||||
id=TEAM_ID,
|
||||
name=TEAM_NAME,
|
||||
slug=TEAM_SLUG,
|
||||
billing_plan=billing_plan,
|
||||
),
|
||||
)
|
||||
provider.audit_config = audit_config or {"max_retries": 3}
|
||||
|
||||
Reference in New Issue
Block a user