mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
chore(vercel): add disclaimer for checks depending on billing plan (#10663)
This commit is contained in:
committed by
GitHub
parent
40dd0e640b
commit
86449fb99d
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+16
-12
@@ -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")
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
"trust-boundaries",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
"trust-boundaries",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
"trust-boundaries",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
"trust-boundaries",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"encryption"
|
||||
"encryption",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
"trust-boundaries",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
"trust-boundaries",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
"internet-exposed",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
"internet-exposed",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
"secrets",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
"secrets",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
"secrets",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
"internet-exposed",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -28,9 +28,10 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"resilience"
|
||||
"resilience",
|
||||
"vercel-pro-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
"Notes": "Required billing plan: Pro or Enterprise."
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
|
||||
+11
-1
@@ -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}) "
|
||||
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
|
||||
+11
-1
@@ -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}) "
|
||||
|
||||
+6
-5
@@ -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."
|
||||
}
|
||||
|
||||
+8
-5
@@ -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"
|
||||
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
|
||||
+11
-1
@@ -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}) "
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
|
||||
+6
-3
@@ -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"
|
||||
|
||||
+3
-2
@@ -29,11 +29,12 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
"trust-boundaries",
|
||||
"vercel-enterprise-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"team_saml_sso_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
"Notes": "Required billing plan: Enterprise."
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
"trust-boundaries",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
"trust-boundaries",
|
||||
"vercel-hobby-plan"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-2
@@ -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."
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
+38
@@ -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
|
||||
|
||||
+38
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+38
@@ -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
|
||||
|
||||
+38
@@ -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."
|
||||
)
|
||||
|
||||
+38
@@ -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."
|
||||
)
|
||||
|
||||
+41
-1
@@ -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."
|
||||
)
|
||||
|
||||
+38
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+80
@@ -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."
|
||||
)
|
||||
|
||||
+38
@@ -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 == ""
|
||||
|
||||
+39
@@ -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 == ""
|
||||
|
||||
+38
@@ -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,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 = {}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user