Skip to content

Commit 86449fb

Browse files
chore(vercel): add disclaimer for checks depending on billing plan (#10663)
1 parent 40dd0e6 commit 86449fb

66 files changed

Lines changed: 968 additions & 78 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/developer-guide/check-metadata-guidelines.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,6 @@ Also is important to keep all code examples as short as possible, including the
215215
| e5 | M365 and Azure Entra checks enabled by or dependent on an E5 license (e.g., advanced threat protection, audit, DLP, and eDiscovery) |
216216
| 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 |
217217
| 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 |
218+
| vercel-hobby-plan | Vercel checks whose audited feature is available on the Hobby plan (and therefore also on Pro and Enterprise plans) |
219+
| 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 |
220+
| vercel-enterprise-plan | Vercel checks whose audited feature requires the Enterprise plan |

docs/developer-guide/checks.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ Provides both code examples and best practice recommendations for addressing the
387387

388388
#### Categories
389389

390-
One or more functional groupings used for execution filtering (e.g., `internet-exposed`). You can define new categories just by adding to this field.
390+
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.
391391

392392
For the complete list of available categories, see [Categories Guidelines](/developer-guide/check-metadata-guidelines#categories-guidelines).
393393

docs/user-guide/providers/vercel/getting-started-vercel.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,25 @@ Prowler for Vercel includes security checks across the following services:
160160
| **Project** | Deployment protection, environment variable security, fork protection, and skew protection |
161161
| **Security** | Web Application Firewall (WAF), rate limiting, IP blocking, and managed rulesets |
162162
| **Team** | SSO enforcement, directory sync, member access, and invitation hygiene |
163+
164+
## Checks With Explicit Plan-Based Behavior
165+
166+
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.
167+
168+
| Check ID | Hobby | Pro | Enterprise | Notes |
169+
|----------|-------|-----|------------|-------|
170+
| `project_password_protection_enabled` | Not available | Available as a paid add-on | Available | Checks password protection for deployments |
171+
| `project_production_deployment_protection_enabled` | Not available | Available with supported paid deployment protection options | Available | Checks protection for production deployments |
172+
| `project_skew_protection_enabled` | Not available | Available | Available | Checks skew protection during rollouts |
173+
| `security_custom_rules_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
174+
| `security_ip_blocking_rules_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
175+
| `team_saml_sso_enabled` | Not available | Available | Available | Checks team SAML SSO configuration |
176+
| `team_saml_sso_enforced` | Not available | Available | Available | Checks SAML SSO enforcement for all team members |
177+
| `team_directory_sync_enabled` | Not available | Not available | Available | Checks SCIM directory sync |
178+
| `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 |
179+
| `security_rate_limiting_configured` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
180+
| `security_waf_enabled` | Not available | Available | Available | Returns `MANUAL` when the firewall configuration cannot be assessed from the API |
181+
182+
<Note>
183+
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.
184+
</Note>

prowler/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
99
- `bedrock_guardrails_configured` check for AWS provider [(#10844)](https://github.com/prowler-cloud/prowler/pull/10844)
1010
- 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)
1111
- ASD Essential Eight Maturity Model compliance framework for AWS (Maturity Level One, Nov 2023) [(#10808)](https://github.com/prowler-cloud/prowler/pull/10808)
12+
- 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)
1213

1314
### 🔄 Changed
1415

prowler/lib/check/models.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@
6262
"e5",
6363
"privilege-escalation",
6464
"ec2-imdsv1",
65+
"vercel-hobby-plan",
66+
"vercel-pro-plan",
67+
"vercel-enterprise-plan",
6568
}
6669
)
6770

@@ -244,14 +247,15 @@ class CheckMetadata(BaseModel):
244247
# store the compliance later if supplied
245248
Compliance: Optional[list[Any]] = Field(default_factory=list)
246249

250+
# TODO: Remove noqa and fix cls vulture errors
247251
@validator("Categories", each_item=True, pre=True, always=True)
248-
def valid_category(cls, value, values):
252+
def valid_category(cls, value, values): # noqa: F841
249253
if not isinstance(value, str):
250254
raise ValueError("Categories must be a list of strings")
251255
value_lower = value.lower()
252256
if not re.match("^[a-z0-9-]+$", value_lower):
253257
raise ValueError(
254-
f"Invalid category: {value}. Categories can only contain lowercase letters, numbers and hyphen '-'"
258+
f"Invalid category: {value}. Categories can only contain lowercase letters, numbers, and hyphen '-'"
255259
)
256260
if (
257261
value_lower not in VALID_CATEGORIES
@@ -279,7 +283,7 @@ def valid_resource_type(resource_type):
279283
return resource_type
280284

281285
@validator("ServiceName", pre=True, always=True)
282-
def validate_service_name(cls, service_name, values):
286+
def validate_service_name(cls, service_name, values): # noqa: F841
283287
if not service_name:
284288
raise ValueError("ServiceName must be a non-empty string")
285289

@@ -296,7 +300,7 @@ def validate_service_name(cls, service_name, values):
296300
return service_name
297301

298302
@validator("CheckID", pre=True, always=True)
299-
def valid_check_id(cls, check_id, values):
303+
def valid_check_id(cls, check_id, values): # noqa: F841
300304
if not check_id:
301305
raise ValueError("CheckID must be a non-empty string")
302306

@@ -309,7 +313,7 @@ def valid_check_id(cls, check_id, values):
309313
return check_id
310314

311315
@validator("CheckTitle", pre=True, always=True)
312-
def validate_check_title(cls, check_title, values):
316+
def validate_check_title(cls, check_title, values): # noqa: F841
313317
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
314318
if len(check_title) > 150:
315319
raise ValueError(
@@ -322,13 +326,13 @@ def validate_check_title(cls, check_title, values):
322326
return check_title
323327

324328
@validator("RelatedUrl", pre=True, always=True)
325-
def validate_related_url(cls, related_url, values):
329+
def validate_related_url(cls, related_url, values): # noqa: F841
326330
if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
327331
raise ValueError("RelatedUrl must be empty. This field is deprecated.")
328332
return related_url
329333

330334
@validator("Remediation")
331-
def validate_recommendation_url(cls, remediation, values):
335+
def validate_recommendation_url(cls, remediation, values): # noqa: F841
332336
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
333337
url = remediation.Recommendation.Url
334338
if url and not url.startswith("https://hub.prowler.com/"):
@@ -338,7 +342,7 @@ def validate_recommendation_url(cls, remediation, values):
338342
return remediation
339343

340344
@validator("CheckType", pre=True, always=True)
341-
def validate_check_type(cls, check_type, values):
345+
def validate_check_type(cls, check_type, values): # noqa: F841
342346
provider = values.get("Provider", "").lower()
343347

344348
# Non-AWS providers must have an empty CheckType list
@@ -367,7 +371,7 @@ def validate_check_type(cls, check_type, values):
367371
return check_type
368372

369373
@validator("Description", pre=True, always=True)
370-
def validate_description(cls, description, values):
374+
def validate_description(cls, description, values): # noqa: F841
371375
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
372376
if len(description) > 400:
373377
raise ValueError(
@@ -376,7 +380,7 @@ def validate_description(cls, description, values):
376380
return description
377381

378382
@validator("Risk", pre=True, always=True)
379-
def validate_risk(cls, risk, values):
383+
def validate_risk(cls, risk, values): # noqa: F841
380384
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
381385
if len(risk) > 400:
382386
raise ValueError(
@@ -385,15 +389,15 @@ def validate_risk(cls, risk, values):
385389
return risk
386390

387391
@validator("ResourceGroup", pre=True, always=True)
388-
def validate_resource_group(cls, resource_group):
392+
def validate_resource_group(cls, resource_group): # noqa: F841
389393
if resource_group and resource_group not in VALID_RESOURCE_GROUPS:
390394
raise ValueError(
391395
f"Invalid ResourceGroup: '{resource_group}'. Must be one of: {', '.join(sorted(VALID_RESOURCE_GROUPS))} or empty string."
392396
)
393397
return resource_group
394398

395399
@validator("AdditionalURLs", pre=True, always=True)
396-
def validate_additional_urls(cls, additional_urls):
400+
def validate_additional_urls(cls, additional_urls): # noqa: F841
397401
if not isinstance(additional_urls, list):
398402
raise ValueError("AdditionalURLs must be a list")
399403

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from typing import Optional
2+
3+
4+
def extract_billing_plan(data: Optional[dict]) -> Optional[str]:
5+
"""Return the Vercel billing plan from a user or team payload.
6+
7+
Vercel's REST API consistently returns the plan identifier at
8+
``data["billing"]["plan"]`` (e.g. ``"hobby"``, ``"pro"``, ``"enterprise"``)
9+
on both ``GET /v2/user`` and ``GET /v2/teams`` responses, even though the
10+
field is not part of the public OpenAPI schema.
11+
"""
12+
if not isinstance(data, dict):
13+
return None
14+
billing = data.get("billing")
15+
if not isinstance(billing, dict):
16+
return None
17+
plan = billing.get("plan")
18+
return plan.lower() if isinstance(plan, str) else None
19+
20+
21+
def plan_reason_suffix(
22+
billing_plan: Optional[str], unsupported_plans: set[str], explanation: str
23+
) -> str:
24+
"""Return a plan-based explanation suffix only when the plan proves it."""
25+
if billing_plan in unsupported_plans:
26+
return f" This may be expected because {explanation}"
27+
return ""

prowler/providers/vercel/lib/service/service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,10 @@ def _get(self, path: str, params: dict = None) -> dict:
8484
)
8585

8686
if response.status_code == 403:
87-
# Plan limitation or permission error — return None for graceful handling
88-
logger.warning(
87+
# Endpoint unavailable for this token/scope; let checks handle it gracefully
88+
logger.info(
8989
f"{self.service} - Access denied for {path} (403). "
90-
"This may be a plan limitation."
90+
"This may be caused by plan or permission restrictions."
9191
)
9292
return None
9393

prowler/providers/vercel/models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class VercelTeamInfo(BaseModel):
2121
id: str
2222
name: str
2323
slug: str
24+
billing_plan: Optional[str] = None
2425

2526

2627
class VercelIdentityInfo(BaseModel):
@@ -29,9 +30,27 @@ class VercelIdentityInfo(BaseModel):
2930
user_id: Optional[str] = None
3031
username: Optional[str] = None
3132
email: Optional[str] = None
33+
billing_plan: Optional[str] = None
3234
team: Optional[VercelTeamInfo] = None
3335
teams: list[VercelTeamInfo] = Field(default_factory=list)
3436

37+
def get_billing_plan_for(self, scope_id: Optional[str]) -> Optional[str]:
38+
"""Return the billing plan for an explicit user or team scope."""
39+
if not scope_id:
40+
return None
41+
42+
if self.team and self.team.id == scope_id and self.team.billing_plan:
43+
return self.team.billing_plan
44+
45+
for team in self.teams:
46+
if team.id == scope_id:
47+
return team.billing_plan
48+
49+
if self.user_id == scope_id:
50+
return self.billing_plan
51+
52+
return None
53+
3554

3655
class VercelOutputOptions(ProviderOutputOptions):
3756
"""Customize output filenames for Vercel scans."""

prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
}
2929
},
3030
"Categories": [
31-
"trust-boundaries"
31+
"trust-boundaries",
32+
"vercel-hobby-plan"
3233
],
3334
"DependsOn": [],
3435
"RelatedTo": [

prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
}
2929
},
3030
"Categories": [
31-
"trust-boundaries"
31+
"trust-boundaries",
32+
"vercel-hobby-plan"
3233
],
3334
"DependsOn": [],
3435
"RelatedTo": [

0 commit comments

Comments
 (0)