mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(sdk): add Vercel provider with 30 security checks (#10189)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com> Co-authored-by: Andoni A. <14891798+andoniaf@users.noreply.github.com>
This commit is contained in:
co-authored by
Daniel Barranquero
Andoni A.
parent
4bb1e5cff7
commit
4f86667433
@@ -67,6 +67,11 @@ provider/googleworkspace:
|
||||
- any-glob-to-any-file: "prowler/providers/googleworkspace/**"
|
||||
- any-glob-to-any-file: "tests/providers/googleworkspace/**"
|
||||
|
||||
provider/vercel:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: "prowler/providers/vercel/**"
|
||||
- any-glob-to-any-file: "tests/providers/vercel/**"
|
||||
|
||||
github_actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ".github/workflows/*"
|
||||
@@ -102,6 +107,8 @@ mutelist:
|
||||
- any-glob-to-any-file: "tests/providers/openstack/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "prowler/providers/googleworkspace/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/providers/googleworkspace/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "prowler/providers/vercel/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/providers/vercel/lib/mutelist/**"
|
||||
|
||||
integration/s3:
|
||||
- changed-files:
|
||||
|
||||
@@ -177,6 +177,14 @@ modules:
|
||||
- tests/providers/llm/**
|
||||
e2e: []
|
||||
|
||||
- name: sdk-vercel
|
||||
match:
|
||||
- prowler/providers/vercel/**
|
||||
- prowler/compliance/vercel/**
|
||||
tests:
|
||||
- tests/providers/vercel/**
|
||||
e2e: []
|
||||
|
||||
# ============================================
|
||||
# SDK - Lib modules
|
||||
# ============================================
|
||||
|
||||
@@ -499,6 +499,30 @@ jobs:
|
||||
flags: prowler-py${{ matrix.python-version }}-googleworkspace
|
||||
files: ./googleworkspace_coverage.xml
|
||||
|
||||
# Vercel Provider
|
||||
- name: Check if Vercel files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-vercel
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/vercel/**
|
||||
./tests/**/vercel/**
|
||||
./poetry.lock
|
||||
|
||||
- name: Run Vercel tests
|
||||
if: steps.changed-vercel.outputs.any_changed == 'true'
|
||||
run: poetry run pytest -n auto --cov=./prowler/providers/vercel --cov-report=xml:vercel_coverage.xml tests/providers/vercel
|
||||
|
||||
- name: Upload Vercel coverage to Codecov
|
||||
if: steps.changed-vercel.outputs.any_changed == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: prowler-py${{ matrix.python-version }}-vercel
|
||||
files: ./vercel_coverage.xml
|
||||
|
||||
# Lib
|
||||
- name: Check if Lib files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -119,6 +119,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
|
||||
| Google Workspace | 1 | 1 | 0 | 1 | Official | CLI |
|
||||
| OpenStack | 27 | 4 | 0 | 8 | Official | UI, API, CLI |
|
||||
| Vercel | 30 | 6 | 0 | 5 | Official | CLI |
|
||||
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
|
||||
|
||||
> [!Note]
|
||||
|
||||
@@ -296,6 +296,13 @@
|
||||
"user-guide/providers/openstack/getting-started-openstack",
|
||||
"user-guide/providers/openstack/authentication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Vercel",
|
||||
"pages": [
|
||||
"user-guide/providers/vercel/getting-started-vercel",
|
||||
"user-guide/providers/vercel/authentication"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ The supported providers right now are:
|
||||
| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI |
|
||||
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
|
||||
| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI |
|
||||
| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | CLI |
|
||||
| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI |
|
||||
| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images | CLI, API |
|
||||
| [Google Workspace](/user-guide/providers/googleworkspace/getting-started-googleworkspace) | Official | Domains | CLI |
|
||||
|
||||
@@ -141,6 +141,22 @@ The following list includes all the GitHub checks with configurable variables th
|
||||
|--------------------------------------------|---------------------------------------------|---------|
|
||||
| `repository_inactive_not_archived` | `inactive_not_archived_days_threshold` | Integer |
|
||||
|
||||
## Vercel
|
||||
|
||||
### Configurable Checks
|
||||
The following list includes all the Vercel checks with configurable variables that can be changed in the configuration YAML file:
|
||||
|
||||
| Check Name | Value | Type |
|
||||
|-----------------------------------------------------|------------------------------------|-----------------|
|
||||
| `authentication_no_stale_tokens` | `stale_token_threshold_days` | Integer |
|
||||
| `authentication_token_not_expired` | `days_to_expire_threshold` | Integer |
|
||||
| `deployment_production_uses_stable_target` | `stable_branches` | List of Strings |
|
||||
| `domain_ssl_certificate_valid` | `days_to_expire_threshold` | Integer |
|
||||
| `project_environment_no_secrets_in_plain_type` | `secret_suffixes` | List of Strings |
|
||||
| `team_member_role_least_privilege` | `max_owner_percentage` | Integer |
|
||||
| `team_member_role_least_privilege` | `max_owners` | Integer |
|
||||
| `team_no_stale_invitations` | `stale_invitation_threshold_days` | Integer |
|
||||
|
||||
## Config YAML File Structure
|
||||
|
||||
<Note>
|
||||
@@ -624,5 +640,29 @@ github:
|
||||
# github.repository_inactive_not_archived
|
||||
inactive_not_archived_days_threshold: 180
|
||||
|
||||
# Vercel Configuration
|
||||
vercel:
|
||||
# vercel.deployment_production_uses_stable_target
|
||||
stable_branches:
|
||||
- "main"
|
||||
- "master"
|
||||
# vercel.authentication_token_not_expired & vercel.domain_ssl_certificate_valid
|
||||
days_to_expire_threshold: 7
|
||||
# vercel.authentication_no_stale_tokens
|
||||
stale_token_threshold_days: 90
|
||||
# vercel.team_no_stale_invitations
|
||||
stale_invitation_threshold_days: 30
|
||||
# vercel.team_member_role_least_privilege
|
||||
max_owner_percentage: 20
|
||||
max_owners: 3
|
||||
# vercel.project_environment_no_secrets_in_plain_type
|
||||
secret_suffixes:
|
||||
- "_KEY"
|
||||
- "_SECRET"
|
||||
- "_TOKEN"
|
||||
- "_PASSWORD"
|
||||
- "_API_KEY"
|
||||
- "_PRIVATE_KEY"
|
||||
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: "Vercel Authentication in Prowler"
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
<VersionBadge version="5.21.0" />
|
||||
|
||||
Prowler for Vercel authenticates using an **API Token**.
|
||||
|
||||
## Required Permissions
|
||||
|
||||
Prowler requires read-only access to Vercel teams, projects, deployments, domains, and security settings. The API Token must have access to the target team scope.
|
||||
|
||||
<Note>
|
||||
Vercel API Tokens inherit the permissions of the user that created them. Ensure the user has at least a **Viewer** role on the team to be scanned.
|
||||
</Note>
|
||||
|
||||
| Resource | Access | Description |
|
||||
|----------|--------|-------------|
|
||||
| Teams | Read | Required to list teams, members, and SSO configuration |
|
||||
| Projects | Read | Required to list projects, environment variables, and deployment protection settings |
|
||||
| Deployments | Read | Required to list deployments and protection status |
|
||||
| Domains | Read | Required to list domains, DNS records, and SSL certificates |
|
||||
| Firewall | Read | Required to read WAF rules, rate limiting, and IP blocking configuration |
|
||||
|
||||
---
|
||||
|
||||
## API Token
|
||||
|
||||
### Step 1: Create an API Token
|
||||
|
||||
1. Log into the [Vercel Dashboard](https://vercel.com/dashboard).
|
||||
2. Click the account avatar in the bottom-left corner and select "Settings".
|
||||
|
||||

|
||||
|
||||
3. In the left sidebar, click "Tokens".
|
||||
4. Under **Create Token**, enter a descriptive name (e.g., "Prowler Scan").
|
||||
5. Select the **Scope** — choose the team to be scanned or "Full Account" for all teams.
|
||||
6. Set an **Expiration** date, or select "No expiration" for continuous scanning.
|
||||
7. Click **Create**.
|
||||
|
||||

|
||||
|
||||
8. Copy the token immediately.
|
||||
|
||||
<Warning>
|
||||
Vercel only displays the token once. Copy it immediately and store it securely. If lost, a new token must be created.
|
||||
</Warning>
|
||||
|
||||
### Step 2: Provide the Token to Prowler
|
||||
|
||||
Export the token as an environment variable:
|
||||
|
||||
```console
|
||||
export VERCEL_TOKEN="your-api-token-here"
|
||||
prowler vercel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Team Scoping (Optional)
|
||||
|
||||
By default, Prowler auto-discovers all teams the authenticated user belongs to and scans each one. To restrict the scan to a specific team, provide the Team ID.
|
||||
|
||||
### Locate the Team ID
|
||||
|
||||
1. In the Vercel Dashboard, navigate to "Settings" for the target team.
|
||||
2. Scroll down to the **Team ID** section and copy the value.
|
||||
|
||||

|
||||
|
||||
### Provide the Team ID to Prowler
|
||||
|
||||
Export the Team ID as an environment variable:
|
||||
|
||||
```console
|
||||
export VERCEL_TOKEN="your-api-token-here"
|
||||
export VERCEL_TEAM="team_yourteamid"
|
||||
prowler vercel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `VERCEL_TOKEN` | Yes | Vercel API Bearer Token |
|
||||
| `VERCEL_TEAM` | No | Team ID or slug to scope the scan to a single team |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Create a dedicated token for Prowler** — Avoid reusing tokens shared with other integrations.
|
||||
- **Use environment variables** — Never hardcode credentials in scripts or commands.
|
||||
- **Scope tokens to specific teams** — When possible, limit token access to the team being scanned.
|
||||
- **Set token expiration** — Use time-limited tokens and rotate them regularly.
|
||||
- **Use least privilege** — Assign the Viewer role to the user creating the token unless write access is explicitly needed.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Vercel credentials not found" Error
|
||||
|
||||
This error occurs when no API Token is provided. Ensure the `VERCEL_TOKEN` environment variable is set:
|
||||
|
||||
```console
|
||||
export VERCEL_TOKEN="your-api-token-here"
|
||||
```
|
||||
|
||||
### "Invalid or expired Vercel API token" Error
|
||||
|
||||
- Verify the API Token is correct and has not expired.
|
||||
- Check that the token has not been revoked in the Vercel Dashboard under "Settings" > "Tokens".
|
||||
|
||||
### "Insufficient permissions" Error
|
||||
|
||||
- Ensure the user that created the token has at least a **Viewer** role on the target team.
|
||||
- If scanning a specific team, verify the token scope includes that team.
|
||||
|
||||
### "Team not found or not accessible" Error
|
||||
|
||||
This error occurs when the provided `VERCEL_TEAM` value does not match an accessible team. Verify the Team ID is correct:
|
||||
|
||||
1. Navigate to the team "Settings" in the Vercel Dashboard.
|
||||
2. Copy the exact **Team ID** value from the settings page.
|
||||
|
||||
### "Rate limit exceeded" Error
|
||||
|
||||
Vercel applies rate limits to API requests. Prowler automatically retries rate-limited requests up to 3 times with exponential backoff. If this error persists:
|
||||
|
||||
- Reduce the number of projects being scanned in a single run using the `--project` argument.
|
||||
- Wait a few minutes and retry the scan.
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
title: "Getting Started With Vercel on Prowler"
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
|
||||
Prowler for Vercel scans teams and projects for security misconfigurations, including deployment protection, environment variable exposure, WAF rules, domain configuration, team access controls, and more.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Set up authentication for Vercel with the [Vercel Authentication](/user-guide/providers/vercel/authentication) guide before starting:
|
||||
|
||||
- Create a Vercel API Token with access to the target team
|
||||
- Identify the Team ID (optional, required to scope the scan to a single team)
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
<VersionBadge version="5.22.0" />
|
||||
|
||||
### Step 1: Set Up Authentication
|
||||
|
||||
Follow the [Vercel Authentication](/user-guide/providers/vercel/authentication) guide to create an API Token, then export it:
|
||||
|
||||
```console
|
||||
export VERCEL_TOKEN="your-api-token-here"
|
||||
```
|
||||
|
||||
Optionally, scope the scan to a specific team:
|
||||
|
||||
```console
|
||||
export VERCEL_TEAM="team_yourteamid"
|
||||
```
|
||||
|
||||
### Step 2: Run the First Scan
|
||||
|
||||
Run a baseline scan after credentials are configured:
|
||||
|
||||
```console
|
||||
prowler vercel
|
||||
```
|
||||
|
||||
Prowler automatically discovers all teams accessible with the provided token and runs security checks against them.
|
||||
|
||||
### Step 3: Filter the Scan Scope (Optional)
|
||||
|
||||
#### Filter by Team
|
||||
|
||||
To scan a specific team, set the `VERCEL_TEAM` environment variable with the Team ID or slug:
|
||||
|
||||
```console
|
||||
export VERCEL_TEAM="team_yourteamid"
|
||||
prowler vercel
|
||||
```
|
||||
|
||||
<Note>
|
||||
When no team is specified, Prowler auto-discovers all teams the authenticated user belongs to and scans each one.
|
||||
</Note>
|
||||
|
||||
#### Filter by Project
|
||||
|
||||
To scan only specific projects, use the `--project` argument:
|
||||
|
||||
```console
|
||||
prowler vercel --project my-project-name
|
||||
```
|
||||
|
||||
Multiple projects can be specified:
|
||||
|
||||
```console
|
||||
prowler vercel --project my-project-name another-project
|
||||
```
|
||||
|
||||
Project IDs are also supported:
|
||||
|
||||
```console
|
||||
prowler vercel --project prj_abc123def456
|
||||
```
|
||||
|
||||
### Step 4: Use a Custom Configuration (Optional)
|
||||
|
||||
Prowler uses a configuration file to customize provider behavior. The Vercel configuration includes:
|
||||
|
||||
```yaml
|
||||
vercel:
|
||||
# Maximum number of retries for API requests (default is 3)
|
||||
max_retries: 3
|
||||
```
|
||||
|
||||
To use a custom configuration:
|
||||
|
||||
```console
|
||||
prowler vercel --config-file /path/to/config.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Services
|
||||
|
||||
Prowler for Vercel includes security checks across the following services:
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| **Authentication** | Token expiration and staleness checks |
|
||||
| **Deployment** | Preview deployment access and production stability |
|
||||
| **Domain** | DNS configuration, SSL certificates, and wildcard exposure |
|
||||
| **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 |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
@@ -14,12 +14,11 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- `ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip` check for AWS provider using `ipaddress.is_global` for accurate public IP detection [(#10335)](https://github.com/prowler-cloud/prowler/pull/10335)
|
||||
- `entra_conditional_access_policy_block_o365_elevated_insider_risk` check for M365 provider [(#10232)](https://github.com/prowler-cloud/prowler/pull/10232)
|
||||
- `--resource-group` and `--list-resource-groups` CLI flags to filter checks by resource group across all providers [(#10479)](https://github.com/prowler-cloud/prowler/pull/10479)
|
||||
- `apikeys_api_restricted_with_gemini_api` check for GCP provider [(#10280)](https://github.com/prowler-cloud/prowler/pull/10280)
|
||||
- `gemini_api_disabled` check for GCP provider [(#10280)](https://github.com/prowler-cloud/prowler/pull/10280)
|
||||
- CISA SCuBA Google Workspace Baselines compliance [(#10466)](https://github.com/prowler-cloud/prowler/pull/10466)
|
||||
- CIS Google Workspace Foundations Benchmark v1.3.0 compliance [(#10462)](https://github.com/prowler-cloud/prowler/pull/10462)
|
||||
- `entra_conditional_access_policy_device_registration_mfa_required` check and `entra_intune_enrollment_sign_in_frequency_every_time` enhancement for M365 provider [(#10222)](https://github.com/prowler-cloud/prowler/pull/10222)
|
||||
- `entra_conditional_access_policy_block_elevated_insider_risk` check for M365 provider [(#10234)](https://github.com/prowler-cloud/prowler/pull/10234)
|
||||
- `Vercel` provider support with 30 checks [(#10189)](https://github.com/prowler-cloud/prowler/pull/10189)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ from prowler.providers.mongodbatlas.models import MongoDBAtlasOutputOptions
|
||||
from prowler.providers.nhn.models import NHNOutputOptions
|
||||
from prowler.providers.openstack.models import OpenStackOutputOptions
|
||||
from prowler.providers.oraclecloud.models import OCIOutputOptions
|
||||
from prowler.providers.vercel.models import VercelOutputOptions
|
||||
|
||||
|
||||
def prowler():
|
||||
@@ -398,6 +399,10 @@ def prowler():
|
||||
output_options = OpenStackOutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
)
|
||||
elif provider == "vercel":
|
||||
output_options = VercelOutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
)
|
||||
|
||||
# Run the quick inventory for the provider if available
|
||||
if hasattr(args, "quick_inventory") and args.quick_inventory:
|
||||
|
||||
@@ -65,6 +65,7 @@ class Provider(str, Enum):
|
||||
ALIBABACLOUD = "alibabacloud"
|
||||
OPENSTACK = "openstack"
|
||||
IMAGE = "image"
|
||||
VERCEL = "vercel"
|
||||
|
||||
|
||||
# Providers that delegate scanning to an external tool (e.g. Trivy, promptfoo)
|
||||
|
||||
@@ -609,3 +609,34 @@ cloudflare:
|
||||
# Maximum number of retries for API requests (default is 2)
|
||||
# Set to 0 to disable retries
|
||||
max_retries: 3
|
||||
|
||||
# Vercel Configuration
|
||||
vercel:
|
||||
# vercel.deployment_production_uses_stable_target
|
||||
# Branches considered stable for production deployments
|
||||
stable_branches:
|
||||
- "main"
|
||||
- "master"
|
||||
# vercel.authentication_token_not_expired & vercel.domain_ssl_certificate_valid
|
||||
# Number of days before expiration to flag a token/certificate as about to expire
|
||||
days_to_expire_threshold: 7
|
||||
# vercel.authentication_no_stale_tokens
|
||||
# Number of days of inactivity before a token is considered stale
|
||||
stale_token_threshold_days: 90
|
||||
# vercel.team_no_stale_invitations
|
||||
# Number of days before a pending invitation is considered stale
|
||||
stale_invitation_threshold_days: 30
|
||||
# vercel.team_member_role_least_privilege
|
||||
# Maximum percentage of team members that can have the OWNER role
|
||||
max_owner_percentage: 20
|
||||
# Maximum number of owners allowed (overrides percentage for large teams)
|
||||
max_owners: 3
|
||||
# vercel.project_environment_no_secrets_in_plain_type
|
||||
# Suffixes that identify secret-like environment variable names
|
||||
secret_suffixes:
|
||||
- "_KEY"
|
||||
- "_SECRET"
|
||||
- "_TOKEN"
|
||||
- "_PASSWORD"
|
||||
- "_API_KEY"
|
||||
- "_PRIVATE_KEY"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
### Account, Check and/or Region can be * to apply for all the cases.
|
||||
### Account == <Vercel Team ID>
|
||||
### Region == * (Vercel is a global service, region is always "global")
|
||||
### Resources and tags are lists that can have either Regex or Keywords.
|
||||
### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
|
||||
### Use an alternation Regex to match one of multiple tags with "ORed" logic.
|
||||
### For each check you can except Accounts, Regions, Resources and/or Tags.
|
||||
########################### MUTELIST EXAMPLE ###########################
|
||||
Mutelist:
|
||||
Accounts:
|
||||
"team_example123":
|
||||
Checks:
|
||||
"project_deployment_protection_enabled":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "prj_internal001"
|
||||
- "prj_internal002"
|
||||
Description: "Mute deployment protection check for internal-only projects"
|
||||
"project_environment_*":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "prj_staging.*"
|
||||
Description: "Mute all environment variable checks for staging projects"
|
||||
"*":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "prj_sandbox"
|
||||
Tags:
|
||||
- "environment=sandbox"
|
||||
Description: "Mute all checks for sandbox project with matching tag"
|
||||
|
||||
"*":
|
||||
Checks:
|
||||
"security_waf_enabled":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "prj_static.*"
|
||||
Description: "Mute WAF check for static-only projects across all teams"
|
||||
"*":
|
||||
Regions:
|
||||
- "*"
|
||||
Resources:
|
||||
- "*"
|
||||
Tags:
|
||||
- "prowler-ignore=true"
|
||||
Description: "Global mute for resources tagged with prowler-ignore=true"
|
||||
@@ -713,6 +713,11 @@ def execute(
|
||||
is_finding_muted_args["project_id"] = (
|
||||
global_provider.identity.project_id
|
||||
)
|
||||
elif global_provider.type == "vercel":
|
||||
team = getattr(global_provider.identity, "team", None)
|
||||
is_finding_muted_args["team_id"] = (
|
||||
team.id if team else global_provider.identity.user_id
|
||||
)
|
||||
for finding in check_findings:
|
||||
if global_provider.type == "cloudflare":
|
||||
is_finding_muted_args["account_id"] = finding.account_id
|
||||
|
||||
@@ -1240,6 +1240,50 @@ class CheckReportMongoDBAtlas(Check_Report):
|
||||
self.location = getattr(resource, "location", self.project_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckReportVercel(Check_Report):
|
||||
"""Contains the Vercel Check's finding information.
|
||||
|
||||
Vercel is a global platform - team_id is the scoping context.
|
||||
All resource-related attributes are derived from the resource object.
|
||||
"""
|
||||
|
||||
resource_name: str
|
||||
resource_id: str
|
||||
team_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
metadata: Dict,
|
||||
resource: Any,
|
||||
resource_name: str = None,
|
||||
resource_id: str = None,
|
||||
team_id: str = None,
|
||||
) -> None:
|
||||
"""Initialize the Vercel Check's finding information.
|
||||
|
||||
Args:
|
||||
metadata: Check metadata dictionary
|
||||
resource: The Vercel resource being checked
|
||||
resource_name: Override for resource name
|
||||
resource_id: Override for resource ID
|
||||
team_id: Override for team ID
|
||||
"""
|
||||
super().__init__(metadata, resource)
|
||||
self.resource_name = resource_name or getattr(
|
||||
resource, "name", getattr(resource, "resource_name", "")
|
||||
)
|
||||
self.resource_id = resource_id or getattr(
|
||||
resource, "id", getattr(resource, "resource_id", "")
|
||||
)
|
||||
self.team_id = team_id or getattr(resource, "team_id", "")
|
||||
|
||||
@property
|
||||
def region(self) -> str:
|
||||
"""Vercel is global - return 'global'."""
|
||||
return "global"
|
||||
|
||||
|
||||
# Testing Pending
|
||||
def load_check_metadata(metadata_file: str) -> CheckMetadata:
|
||||
"""
|
||||
|
||||
@@ -29,10 +29,10 @@ class ProwlerArgumentParser:
|
||||
self.parser = argparse.ArgumentParser(
|
||||
prog="prowler",
|
||||
formatter_class=RawTextHelpFormatter,
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ...",
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image} ...",
|
||||
epilog="""
|
||||
Available Cloud Providers:
|
||||
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack}
|
||||
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel}
|
||||
aws AWS Provider
|
||||
azure Azure Provider
|
||||
gcp GCP Provider
|
||||
@@ -49,6 +49,7 @@ Available Cloud Providers:
|
||||
image Container Image Provider
|
||||
nhn NHN Provider (Unofficial)
|
||||
mongodbatlas MongoDB Atlas Provider (Beta)
|
||||
vercel Vercel Provider
|
||||
|
||||
Available components:
|
||||
dashboard Local dashboard
|
||||
|
||||
@@ -404,6 +404,23 @@ class Finding(BaseModel):
|
||||
output_data["resource_uid"] = check_output.resource_id
|
||||
output_data["region"] = check_output.zone_name
|
||||
|
||||
elif provider.type == "vercel":
|
||||
output_data["auth_method"] = "api_token"
|
||||
team = get_nested_attribute(provider, "identity.team")
|
||||
output_data["account_uid"] = (
|
||||
team.id
|
||||
if team
|
||||
else get_nested_attribute(provider, "identity.user_id")
|
||||
)
|
||||
output_data["account_name"] = (
|
||||
team.name
|
||||
if team
|
||||
else get_nested_attribute(provider, "identity.username")
|
||||
)
|
||||
output_data["resource_name"] = check_output.resource_name
|
||||
output_data["resource_uid"] = check_output.resource_id
|
||||
output_data["region"] = "global"
|
||||
|
||||
elif provider.type == "alibabacloud":
|
||||
output_data["auth_method"] = get_nested_attribute(
|
||||
provider, "identity.identity_arn"
|
||||
|
||||
@@ -1332,6 +1332,71 @@ class HTML(Output):
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def get_vercel_assessment_summary(provider: Provider) -> str:
|
||||
"""
|
||||
get_vercel_assessment_summary gets the HTML assessment summary for the Vercel provider
|
||||
|
||||
Args:
|
||||
provider (Provider): the Vercel provider object
|
||||
|
||||
Returns:
|
||||
str: HTML assessment summary for the Vercel provider
|
||||
"""
|
||||
try:
|
||||
assessment_items = ""
|
||||
|
||||
team = getattr(provider.identity, "team", None)
|
||||
if team:
|
||||
assessment_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Team:</b> {team.name} ({team.id})
|
||||
</li>"""
|
||||
|
||||
credentials_items = """
|
||||
<li class="list-group-item">
|
||||
<b>Authentication:</b> API Token
|
||||
</li>"""
|
||||
|
||||
email = getattr(provider.identity, "email", None)
|
||||
if email:
|
||||
credentials_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Email:</b> {email}
|
||||
</li>"""
|
||||
|
||||
username = getattr(provider.identity, "username", None)
|
||||
if username:
|
||||
credentials_items += f"""
|
||||
<li class="list-group-item">
|
||||
<b>Username:</b> {username}
|
||||
</li>"""
|
||||
|
||||
return f"""
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Vercel Assessment Summary
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">{assessment_items}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Vercel Credentials
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">{credentials_items}
|
||||
</ul>
|
||||
</div>
|
||||
</div>"""
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def get_assessment_summary(provider: Provider) -> str:
|
||||
"""
|
||||
|
||||
@@ -38,6 +38,8 @@ def stdout_report(finding, color, verbose, status, fix):
|
||||
details = finding.zone_name
|
||||
if finding.check_metadata.Provider == "googleworkspace":
|
||||
details = finding.location
|
||||
if finding.check_metadata.Provider == "vercel":
|
||||
details = finding.region
|
||||
|
||||
if (verbose or fix) and (not status or finding.status in status):
|
||||
if finding.muted:
|
||||
|
||||
@@ -99,6 +99,14 @@ def display_summary_table(
|
||||
elif provider.type == "image":
|
||||
entity_type = "Image"
|
||||
audited_entities = ", ".join(provider.images)
|
||||
elif provider.type == "vercel":
|
||||
entity_type = "Team"
|
||||
if provider.identity.team:
|
||||
audited_entities = (
|
||||
f"{provider.identity.team.name} ({provider.identity.team.slug})"
|
||||
)
|
||||
else:
|
||||
audited_entities = provider.identity.username or "Personal Account"
|
||||
|
||||
# Check if there are findings and that they are not all MANUAL
|
||||
if findings and not all(finding.status == "MANUAL" for finding in findings):
|
||||
|
||||
@@ -371,6 +371,13 @@ class Provider(ABC):
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
elif "vercel" in provider_class_name.lower():
|
||||
provider_class(
|
||||
projects=getattr(arguments, "project", None),
|
||||
config_path=arguments.config_file,
|
||||
mutelist_path=arguments.mutelist_file,
|
||||
fixer_config=fixer_config,
|
||||
)
|
||||
|
||||
except TypeError as error:
|
||||
logger.critical(
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Exceptions codes from 13000 to 13999 are reserved for Vercel exceptions
|
||||
from prowler.exceptions.exceptions import ProwlerException
|
||||
|
||||
|
||||
class VercelBaseException(ProwlerException):
|
||||
"""Base exception for Vercel provider errors."""
|
||||
|
||||
VERCEL_ERROR_CODES = {
|
||||
(13000, "VercelCredentialsError"): {
|
||||
"message": "Vercel credentials not found or invalid.",
|
||||
"remediation": "Set the VERCEL_TOKEN environment variable with a valid Vercel API token. Generate one at https://vercel.com/account/tokens.",
|
||||
},
|
||||
(13001, "VercelAuthenticationError"): {
|
||||
"message": "Authentication to Vercel API failed.",
|
||||
"remediation": "Verify your Vercel API token is valid and has not expired. Check at https://vercel.com/account/tokens.",
|
||||
},
|
||||
(13002, "VercelSessionError"): {
|
||||
"message": "Failed to create a Vercel API session.",
|
||||
"remediation": "Check network connectivity and ensure the Vercel API is reachable at https://api.vercel.com.",
|
||||
},
|
||||
(13003, "VercelIdentityError"): {
|
||||
"message": "Failed to retrieve Vercel identity information.",
|
||||
"remediation": "Ensure the API token has permissions to read user and team information.",
|
||||
},
|
||||
(13004, "VercelInvalidTeamError"): {
|
||||
"message": "The specified Vercel team was not found or is not accessible.",
|
||||
"remediation": "Verify the team ID or slug is correct and that your token has access to the team.",
|
||||
},
|
||||
(13005, "VercelInvalidProviderIdError"): {
|
||||
"message": "The provided Vercel provider ID is invalid.",
|
||||
"remediation": "Ensure the provider UID matches a valid Vercel team ID or user ID format.",
|
||||
},
|
||||
(13006, "VercelAPIError"): {
|
||||
"message": "An error occurred while calling the Vercel API.",
|
||||
"remediation": "Check the Vercel API status at https://www.vercel-status.com/ and retry the request.",
|
||||
},
|
||||
(13007, "VercelRateLimitError"): {
|
||||
"message": "Rate limited by the Vercel API.",
|
||||
"remediation": "Wait and retry. Vercel API rate limits vary by endpoint. See https://vercel.com/docs/rest-api#rate-limits.",
|
||||
},
|
||||
(13008, "VercelPlanLimitationError"): {
|
||||
"message": "This feature requires a higher Vercel plan.",
|
||||
"remediation": "Some security features (e.g., WAF managed rulesets) require Vercel Enterprise. Upgrade your plan or skip these checks.",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
provider = "Vercel"
|
||||
error_info = self.VERCEL_ERROR_CODES.get((code, self.__class__.__name__))
|
||||
if error_info is None:
|
||||
error_info = {
|
||||
"message": message or "Unknown Vercel error.",
|
||||
"remediation": "Check the Vercel API documentation for more details.",
|
||||
}
|
||||
elif message:
|
||||
error_info = error_info.copy()
|
||||
error_info["message"] = message
|
||||
super().__init__(
|
||||
code=code,
|
||||
source=provider,
|
||||
file=file,
|
||||
original_exception=original_exception,
|
||||
error_info=error_info,
|
||||
)
|
||||
|
||||
|
||||
class VercelCredentialsError(VercelBaseException):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
13000, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class VercelAuthenticationError(VercelBaseException):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
13001, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class VercelSessionError(VercelBaseException):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
13002, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class VercelIdentityError(VercelBaseException):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
13003, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class VercelInvalidTeamError(VercelBaseException):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
13004, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class VercelInvalidProviderIdError(VercelBaseException):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
13005, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class VercelAPIError(VercelBaseException):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
13006, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class VercelRateLimitError(VercelBaseException):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
13007, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class VercelPlanLimitationError(VercelBaseException):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
13008, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
def init_parser(self):
|
||||
"""Init the Vercel provider CLI parser."""
|
||||
vercel_parser = self.subparsers.add_parser(
|
||||
"vercel",
|
||||
parents=[self.common_providers_parser],
|
||||
help="Vercel Provider",
|
||||
)
|
||||
|
||||
# Scope
|
||||
scope_group = vercel_parser.add_argument_group("Scope")
|
||||
scope_group.add_argument(
|
||||
"--project",
|
||||
"--projects",
|
||||
nargs="*",
|
||||
default=None,
|
||||
metavar="PROJECT",
|
||||
help="Filter scan to specific Vercel project names or IDs.",
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from prowler.lib.check.models import CheckReportVercel
|
||||
from prowler.lib.mutelist.mutelist import Mutelist
|
||||
from prowler.lib.outputs.utils import unroll_dict, unroll_tags
|
||||
|
||||
|
||||
class VercelMutelist(Mutelist):
|
||||
"""Vercel-specific mutelist helper."""
|
||||
|
||||
def is_finding_muted(
|
||||
self,
|
||||
finding: CheckReportVercel,
|
||||
team_id: str,
|
||||
) -> bool:
|
||||
return self.is_muted(
|
||||
team_id,
|
||||
finding.check_metadata.CheckID,
|
||||
"global", # Vercel is a global service
|
||||
finding.resource_id or finding.resource_name,
|
||||
unroll_dict(unroll_tags(finding.resource_tags)),
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.vercel.exceptions.exceptions import (
|
||||
VercelAPIError,
|
||||
VercelRateLimitError,
|
||||
)
|
||||
|
||||
MAX_WORKERS = 10
|
||||
|
||||
|
||||
class VercelService:
|
||||
"""Base class for Vercel services to share provider context and HTTP client."""
|
||||
|
||||
def __init__(self, service: str, provider):
|
||||
self.provider = provider
|
||||
self.audit_config = provider.audit_config
|
||||
self.fixer_config = provider.fixer_config
|
||||
self.service = service.lower() if not service.islower() else service
|
||||
|
||||
# Set up HTTP session with Bearer token
|
||||
self._http_session = requests.Session()
|
||||
self._http_session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {provider.session.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
self._base_url = provider.session.base_url
|
||||
self._team_id = provider.session.team_id
|
||||
|
||||
# Thread pool for parallel API calls
|
||||
self.thread_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
||||
|
||||
@property
|
||||
def _all_team_ids(self) -> list[str]:
|
||||
"""Return team IDs to scan: explicit team_id, or all auto-discovered teams."""
|
||||
if self._team_id:
|
||||
return [self._team_id]
|
||||
return [t.id for t in self.provider.identity.teams]
|
||||
|
||||
def _get(self, path: str, params: dict = None) -> dict:
|
||||
"""Make a rate-limit-aware GET request to the Vercel API.
|
||||
|
||||
Args:
|
||||
path: API path (e.g., "/v9/projects").
|
||||
params: Query parameters.
|
||||
|
||||
Returns:
|
||||
Parsed JSON response as dict.
|
||||
|
||||
Raises:
|
||||
VercelRateLimitError: If rate limited after retries.
|
||||
VercelAPIError: If the API returns an error.
|
||||
"""
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
# Append teamId if operating in team scope
|
||||
if self._team_id and "teamId" not in params:
|
||||
params["teamId"] = self._team_id
|
||||
|
||||
url = f"{self._base_url}{path}"
|
||||
max_retries = self.audit_config.get("max_retries", 3)
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = self._http_session.get(url, params=params, timeout=30)
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get("Retry-After", 5))
|
||||
if attempt < max_retries:
|
||||
logger.warning(
|
||||
f"{self.service} - Rate limited, retrying after {retry_after}s (attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
time.sleep(retry_after)
|
||||
continue
|
||||
raise VercelRateLimitError(
|
||||
file=__file__,
|
||||
message=f"Rate limited on {path} after {max_retries} retries.",
|
||||
)
|
||||
|
||||
if response.status_code == 403:
|
||||
# Plan limitation or permission error — return None for graceful handling
|
||||
logger.warning(
|
||||
f"{self.service} - Access denied for {path} (403). "
|
||||
"This may be a plan limitation."
|
||||
)
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except VercelRateLimitError:
|
||||
raise
|
||||
except requests.exceptions.HTTPError as error:
|
||||
raise VercelAPIError(
|
||||
file=__file__,
|
||||
original_exception=error,
|
||||
message=f"HTTP error on {path}: {error}",
|
||||
)
|
||||
except requests.exceptions.RequestException as error:
|
||||
if attempt < max_retries:
|
||||
logger.warning(
|
||||
f"{self.service} - Request error on {path}, retrying (attempt {attempt + 1}/{max_retries}): {error}"
|
||||
)
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
raise VercelAPIError(
|
||||
file=__file__,
|
||||
original_exception=error,
|
||||
message=f"Request failed on {path} after {max_retries} retries: {error}",
|
||||
)
|
||||
|
||||
return {}
|
||||
|
||||
def _paginate(self, path: str, key: str, params: dict = None) -> list:
|
||||
"""Paginate through a Vercel API list endpoint.
|
||||
|
||||
Vercel uses cursor-based pagination with a `pagination.next` field.
|
||||
|
||||
Args:
|
||||
path: API path.
|
||||
key: JSON key containing the list of items.
|
||||
params: Additional query parameters.
|
||||
|
||||
Returns:
|
||||
Combined list of all items across pages.
|
||||
"""
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
params["limit"] = params.get("limit", 100)
|
||||
all_items = []
|
||||
|
||||
while True:
|
||||
data = self._get(path, params)
|
||||
if data is None:
|
||||
break
|
||||
|
||||
items = data.get(key, [])
|
||||
all_items.extend(items)
|
||||
|
||||
# Check for next page cursor
|
||||
pagination = data.get("pagination", {})
|
||||
next_cursor = pagination.get("next")
|
||||
if not next_cursor:
|
||||
break
|
||||
|
||||
params["until"] = next_cursor
|
||||
|
||||
return all_items
|
||||
|
||||
def __threading_call__(self, call, iterator):
|
||||
"""Execute a function across multiple items using threading."""
|
||||
items = list(iterator) if not isinstance(iterator, list) else iterator
|
||||
|
||||
futures = {self.thread_pool.submit(call, item): item for item in items}
|
||||
results = []
|
||||
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
result = future.result()
|
||||
if result is not None:
|
||||
results.append(result)
|
||||
except Exception as error:
|
||||
item = futures[future]
|
||||
item_id = getattr(item, "id", str(item))
|
||||
logger.error(
|
||||
f"{self.service} - Threading error processing {item_id}: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,52 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from prowler.config.config import output_file_timestamp
|
||||
from prowler.providers.common.models import ProviderOutputOptions
|
||||
|
||||
|
||||
class VercelSession(BaseModel):
|
||||
"""Vercel API session information."""
|
||||
|
||||
token: str
|
||||
team_id: Optional[str] = None
|
||||
base_url: str = "https://api.vercel.com"
|
||||
http_session: Any = Field(default=None, exclude=True)
|
||||
|
||||
|
||||
class VercelTeamInfo(BaseModel):
|
||||
"""Vercel team metadata."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
class VercelIdentityInfo(BaseModel):
|
||||
"""Vercel identity and scoping information."""
|
||||
|
||||
user_id: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
team: Optional[VercelTeamInfo] = None
|
||||
teams: list[VercelTeamInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VercelOutputOptions(ProviderOutputOptions):
|
||||
"""Customize output filenames for Vercel scans."""
|
||||
|
||||
def __init__(self, arguments, bulk_checks_metadata, identity: VercelIdentityInfo):
|
||||
super().__init__(arguments, bulk_checks_metadata)
|
||||
if (
|
||||
not hasattr(arguments, "output_filename")
|
||||
or arguments.output_filename is None
|
||||
):
|
||||
account_fragment = (
|
||||
identity.team.slug if identity.team else identity.username or "vercel"
|
||||
)
|
||||
self.output_filename = (
|
||||
f"prowler-output-{account_fragment}-{output_file_timestamp}"
|
||||
)
|
||||
else:
|
||||
self.output_filename = arguments.output_filename
|
||||
@@ -0,0 +1,6 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.authentication.authentication_service import (
|
||||
Authentication,
|
||||
)
|
||||
|
||||
authentication_client = Authentication(Provider.get_global_provider())
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "authentication_no_stale_tokens",
|
||||
"CheckTitle": "Vercel API tokens are not stale or unused for over 90 days",
|
||||
"CheckType": [],
|
||||
"ServiceName": "authentication",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "governance",
|
||||
"Description": "**Vercel API tokens** are assessed for **staleness** by checking whether each token has been active within the last 90 days. Stale tokens that remain unused for extended periods represent unnecessary access credentials that increase the attack surface. Tokens with no recorded activity are also flagged.",
|
||||
"Risk": "Stale tokens that have not been used for over **90 days** may belong to decommissioned integrations, former team members, or forgotten automation. These tokens remain **valid** and could be compromised or misused without detection, as their inactivity makes suspicious usage harder to notice in access logs.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/rest-api#authentication"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to Account Settings > Tokens\n3. Review the last active date for each token\n4. Revoke or delete tokens that have not been used in over 90 days\n5. Contact token owners to confirm whether the token is still needed\n6. Implement a regular token review process (e.g., quarterly)",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Regularly audit API tokens and revoke any that have not been used within 90 days. Implement a token lifecycle management process that includes periodic reviews, automatic expiration dates, and documentation of each token's purpose and owner.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/authentication_no_stale_tokens"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"authentication_token_not_expired"
|
||||
],
|
||||
"Notes": "The stale threshold is configurable via ``stale_token_threshold_days`` in audit_config (default: 90 days). Tokens with no recorded activity (active_at is None) are considered stale."
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.authentication.authentication_client import (
|
||||
authentication_client,
|
||||
)
|
||||
|
||||
|
||||
class authentication_no_stale_tokens(Check):
|
||||
"""Check if API tokens have been used recently.
|
||||
|
||||
This class verifies whether each Vercel API token has been active within
|
||||
the configured threshold (default: 90 days). Stale tokens that remain
|
||||
unused pose a security risk as they may have been forgotten or belong
|
||||
to former team members.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Stale Token check.
|
||||
|
||||
Iterates over all tokens and checks if each token has been active
|
||||
within the configured threshold. The threshold is configurable via
|
||||
``stale_token_threshold_days`` in audit_config (default: 90 days).
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each token.
|
||||
"""
|
||||
findings = []
|
||||
now = datetime.now(timezone.utc)
|
||||
stale_threshold_days = authentication_client.audit_config.get(
|
||||
"stale_token_threshold_days", 90
|
||||
)
|
||||
stale_cutoff = now - timedelta(days=stale_threshold_days)
|
||||
|
||||
for token in authentication_client.tokens.values():
|
||||
report = CheckReportVercel(
|
||||
metadata=self.metadata(),
|
||||
resource=token,
|
||||
resource_name=token.name,
|
||||
resource_id=token.id,
|
||||
)
|
||||
|
||||
if token.active_at is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) has no recorded activity "
|
||||
f"and is considered stale."
|
||||
)
|
||||
elif token.active_at < stale_cutoff:
|
||||
days_inactive = (now - token.active_at).days
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) has not been used for "
|
||||
f"{days_inactive} days (last active: "
|
||||
f"{token.active_at.strftime('%Y-%m-%d %H:%M UTC')}). "
|
||||
f"Threshold is {stale_threshold_days} days."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) was last active on "
|
||||
f"{token.active_at.strftime('%Y-%m-%d %H:%M UTC')} "
|
||||
f"(within the last {stale_threshold_days} days)."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.vercel.lib.service.service import VercelService
|
||||
|
||||
|
||||
class Authentication(VercelService):
|
||||
"""Retrieve Vercel API token metadata for hygiene checks."""
|
||||
|
||||
def __init__(self, provider):
|
||||
super().__init__("Authentication", provider)
|
||||
self.tokens: dict[str, VercelAuthToken] = {}
|
||||
self._list_tokens()
|
||||
|
||||
def _list_tokens(self):
|
||||
"""List all API tokens for the authenticated user and their teams."""
|
||||
# Always fetch personal tokens (no teamId filter)
|
||||
self._fetch_tokens_for_scope(team_id=None)
|
||||
|
||||
# Also fetch tokens scoped to each team
|
||||
for tid in self._all_team_ids:
|
||||
self._fetch_tokens_for_scope(team_id=tid)
|
||||
|
||||
logger.info(f"Authentication - Found {len(self.tokens)} token(s)")
|
||||
|
||||
def _fetch_tokens_for_scope(self, team_id: str = None):
|
||||
"""Fetch tokens for a specific scope (personal or team).
|
||||
|
||||
Args:
|
||||
team_id: Team ID to fetch tokens for. None for personal tokens.
|
||||
"""
|
||||
try:
|
||||
# Always set teamId key explicitly — _get won't auto-inject when key
|
||||
# is present, and requests skips None values from query params.
|
||||
params = {"teamId": team_id}
|
||||
data = self._get("/v5/user/tokens", params=params)
|
||||
if not data:
|
||||
return
|
||||
|
||||
tokens = data.get("tokens", [])
|
||||
|
||||
for token in tokens:
|
||||
token_id = token.get("id", "")
|
||||
if not token_id or token_id in self.tokens:
|
||||
continue
|
||||
|
||||
active_at = None
|
||||
if token.get("activeAt"):
|
||||
active_at = datetime.fromtimestamp(
|
||||
token["activeAt"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
created_at = None
|
||||
if token.get("createdAt"):
|
||||
created_at = datetime.fromtimestamp(
|
||||
token["createdAt"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
expires_at = None
|
||||
if token.get("expiresAt"):
|
||||
expires_at = datetime.fromtimestamp(
|
||||
token["expiresAt"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
self.tokens[token_id] = VercelAuthToken(
|
||||
id=token_id,
|
||||
name=token.get("name", "Unnamed Token"),
|
||||
type=token.get("type"),
|
||||
active_at=active_at,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
scopes=token.get("scopes", []),
|
||||
origin=token.get("origin"),
|
||||
team_id=token.get("teamId") or team_id,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
scope = f"team {team_id}" if team_id else "personal"
|
||||
logger.error(
|
||||
f"Authentication - Error listing tokens for {scope}: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class VercelAuthToken(BaseModel):
|
||||
"""Vercel API token representation."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: Optional[str] = None
|
||||
active_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
scopes: list[dict] = Field(default_factory=list)
|
||||
origin: Optional[str] = None
|
||||
team_id: Optional[str] = None
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "authentication_token_not_expired",
|
||||
"CheckTitle": "Vercel API tokens have not expired",
|
||||
"CheckType": [],
|
||||
"ServiceName": "authentication",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Vercel API tokens** are assessed for **expiration status** to identify expired tokens or those about to expire within a configurable threshold (default: 7 days). Tokens about to expire are flagged proactively so they can be rotated before causing disruptions. Tokens without an expiration date are considered valid.",
|
||||
"Risk": "Expired tokens indicate poor **token lifecycle management**. Tokens about to expire risk **imminent service disruption** if not rotated in time. Integrations or **CI/CD pipelines** relying on expired or soon-to-expire tokens will fail silently.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/rest-api#authentication"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to Account Settings > Tokens\n3. Identify any expired tokens\n4. Delete expired tokens that are no longer needed\n5. Create new tokens with appropriate expiration dates to replace expired ones\n6. Update any integrations or CI/CD pipelines that used the expired tokens",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Remove expired tokens and create new ones with appropriate expiration dates. Implement a token rotation schedule to ensure tokens are refreshed before they expire. Update all integrations and automation that depend on the replaced tokens.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/authentication_token_not_expired"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"authentication_no_stale_tokens"
|
||||
],
|
||||
"Notes": "Tokens without an expiration date (expires_at is None) are treated as valid since they have no defined expiry. The days_to_expire_threshold is configurable via audit_config (default: 7 days). Tokens expiring within the threshold are reported with medium severity; already expired tokens are reported with high severity."
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel, Severity
|
||||
from prowler.providers.vercel.services.authentication.authentication_client import (
|
||||
authentication_client,
|
||||
)
|
||||
|
||||
|
||||
class authentication_token_not_expired(Check):
|
||||
"""Check if API tokens have not expired or are about to expire.
|
||||
|
||||
This class verifies whether each Vercel API token is still valid by
|
||||
checking its expiration date against the current time. Tokens expiring
|
||||
within a configurable threshold (default: 7 days) are flagged as
|
||||
about to expire with medium severity.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Token Expiration check.
|
||||
|
||||
Iterates over all tokens and checks if each token has expired or
|
||||
is about to expire soon. The threshold is configurable via
|
||||
``days_to_expire_threshold`` in audit_config (default: 7 days).
|
||||
Tokens without an expiration date are considered valid (no expiry set).
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each token.
|
||||
"""
|
||||
findings = []
|
||||
now = datetime.now(timezone.utc)
|
||||
days_to_expire_threshold = authentication_client.audit_config.get(
|
||||
"days_to_expire_threshold", 7
|
||||
)
|
||||
for token in authentication_client.tokens.values():
|
||||
report = CheckReportVercel(
|
||||
metadata=self.metadata(),
|
||||
resource=token,
|
||||
resource_name=token.name,
|
||||
resource_id=token.id,
|
||||
)
|
||||
|
||||
if token.expires_at is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) does not have an expiration "
|
||||
f"date set and is currently valid."
|
||||
)
|
||||
elif token.expires_at <= now:
|
||||
report.status = "FAIL"
|
||||
report.check_metadata.Severity = Severity.high
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) has expired "
|
||||
f"on {token.expires_at.strftime('%Y-%m-%d %H:%M UTC')}."
|
||||
)
|
||||
else:
|
||||
days_left = (token.expires_at - now).days
|
||||
if days_left <= days_to_expire_threshold:
|
||||
report.status = "FAIL"
|
||||
report.check_metadata.Severity = Severity.medium
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) is about to expire "
|
||||
f"in {days_left} days "
|
||||
f"on {token.expires_at.strftime('%Y-%m-%d %H:%M UTC')}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) is valid and expires "
|
||||
f"on {token.expires_at.strftime('%Y-%m-%d %H:%M UTC')}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.deployment.deployment_service import Deployment
|
||||
|
||||
deployment_client = Deployment(Provider.get_global_provider())
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "deployment_production_uses_stable_target",
|
||||
"CheckTitle": "Vercel production deployments originate from a stable branch",
|
||||
"CheckType": [],
|
||||
"ServiceName": "deployment",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "devops",
|
||||
"Description": "**Vercel production deployments** are assessed for **source branch stability** by verifying they are sourced from a stable branch (`main` or `master`). Deploying to production from feature branches bypasses standard CI/CD review processes and may introduce untested or incomplete code into the production environment.",
|
||||
"Risk": "Production deployments from **feature branches** may contain untested, incomplete, or unapproved code changes. This bypasses the standard **code review and merge workflow**, increasing the risk of shipping bugs, security vulnerabilities, or breaking changes to end users.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/deployments/git"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Git\n3. Ensure the Production Branch is set to 'main' or 'master'\n4. Review recent production deployments and revert any that originated from feature branches",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Configure the production branch to main or master and ensure all production deployments go through the standard merge workflow. Use branch protection rules in your Git provider to prevent direct pushes to the production branch.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/deployment_production_uses_stable_target"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Deployments without git source information are skipped as they may be manual deployments or CLI-triggered builds."
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.deployment.deployment_client import (
|
||||
deployment_client,
|
||||
)
|
||||
|
||||
|
||||
class deployment_production_uses_stable_target(Check):
|
||||
"""Check if production deployments are sourced from a stable branch.
|
||||
|
||||
This class verifies whether each Vercel production deployment originates
|
||||
from a configured stable branch rather than a feature branch. The list of
|
||||
stable branches is configurable via audit_config key ``stable_branches``
|
||||
(default: ``["main", "master"]``).
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Production Deployment Stable Target check.
|
||||
|
||||
Iterates over all deployments, filters for production targets with
|
||||
git source information, and checks if the branch is main or master.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each production deployment.
|
||||
"""
|
||||
findings = []
|
||||
for deployment in deployment_client.deployments.values():
|
||||
if deployment.target != "production":
|
||||
continue
|
||||
|
||||
if not deployment.git_source:
|
||||
continue
|
||||
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=deployment)
|
||||
|
||||
stable_branches = deployment_client.audit_config.get(
|
||||
"stable_branches", ["main", "master"]
|
||||
)
|
||||
branch = deployment.git_source.get("branch") or ""
|
||||
if branch in stable_branches:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Production deployment {deployment.name} ({deployment.id}) "
|
||||
f"is sourced from stable branch '{branch}'."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Production deployment {deployment.name} ({deployment.id}) "
|
||||
f"is sourced from branch '{branch}' instead of a "
|
||||
f"configured stable branch ({', '.join(stable_branches)})."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,103 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.vercel.lib.service.service import VercelService
|
||||
|
||||
|
||||
class Deployment(VercelService):
|
||||
"""Retrieve recent Vercel deployments."""
|
||||
|
||||
def __init__(self, provider):
|
||||
super().__init__("Deployment", provider)
|
||||
self.deployments: dict[str, VercelDeployment] = {}
|
||||
self._list_deployments()
|
||||
|
||||
def _list_deployments(self):
|
||||
"""List recent deployments across all projects."""
|
||||
try:
|
||||
params = {"limit": 100}
|
||||
# Fetch only recent deployments (first page is sufficient for security checks)
|
||||
raw_deployments = self._paginate("/v6/deployments", "deployments", params)
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
filter_projects = self.provider.filter_projects
|
||||
|
||||
for dep in raw_deployments:
|
||||
dep_id = dep.get("uid", dep.get("id", ""))
|
||||
if not dep_id or dep_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(dep_id)
|
||||
|
||||
project_id = dep.get("projectId", "")
|
||||
|
||||
# Apply project filter if specified
|
||||
if filter_projects and project_id not in filter_projects:
|
||||
project_name = dep.get("name", "")
|
||||
if project_name not in filter_projects:
|
||||
continue
|
||||
|
||||
created_at = None
|
||||
if dep.get("createdAt"):
|
||||
created_at = datetime.fromtimestamp(
|
||||
dep["createdAt"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
ready_at = None
|
||||
if dep.get("ready"):
|
||||
ready_at = datetime.fromtimestamp(
|
||||
dep["ready"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
git_source = None
|
||||
meta = dep.get("meta", {}) or {}
|
||||
if meta.get("githubCommitSha") or meta.get("gitlabCommitSha"):
|
||||
git_source = {
|
||||
"commit_sha": meta.get("githubCommitSha")
|
||||
or meta.get("gitlabCommitSha"),
|
||||
"branch": meta.get("githubCommitRef")
|
||||
or meta.get("gitlabCommitRef"),
|
||||
"repo": meta.get("githubRepo") or meta.get("gitlabRepo"),
|
||||
}
|
||||
|
||||
self.deployments[dep_id] = VercelDeployment(
|
||||
id=dep_id,
|
||||
name=dep.get("name", ""),
|
||||
url=dep.get("url", ""),
|
||||
state=dep.get("state", dep.get("readyState", "")),
|
||||
target=dep.get("target"),
|
||||
created_at=created_at,
|
||||
ready_at=ready_at,
|
||||
project_id=project_id,
|
||||
project_name=dep.get("name", ""),
|
||||
team_id=dep.get("teamId") or self.provider.session.team_id,
|
||||
git_source=git_source,
|
||||
deployment_protection=dep.get("deploymentProtection"),
|
||||
)
|
||||
|
||||
logger.info(f"Deployment - Found {len(self.deployments)} deployment(s)")
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Deployment - Error listing deployments: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class VercelDeployment(BaseModel):
|
||||
"""Vercel deployment representation."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
url: str = ""
|
||||
state: str = ""
|
||||
target: Optional[str] = None # "production" | "preview"
|
||||
created_at: Optional[datetime] = None
|
||||
ready_at: Optional[datetime] = None
|
||||
project_id: Optional[str] = None
|
||||
project_name: Optional[str] = None
|
||||
team_id: Optional[str] = None
|
||||
git_source: Optional[dict] = None
|
||||
deployment_protection: Optional[dict] = None
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.domain.domain_service import Domain
|
||||
|
||||
domain_client = Domain(Provider.get_global_provider())
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "domain_dns_properly_configured",
|
||||
"CheckTitle": "Vercel domain DNS records are properly configured",
|
||||
"CheckType": [],
|
||||
"ServiceName": "domain",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Vercel domains** are assessed for **DNS configuration** to verify records properly point to Vercel's infrastructure. Misconfigured DNS can result in domains that fail to serve content, SSL certificate provisioning failures, and degraded user experience.",
|
||||
"Risk": "**Misconfigured DNS records** can cause the domain to be unreachable, preventing users from accessing the application. It can also prevent **SSL certificate provisioning**, resulting in browser security warnings. Stale DNS configurations may point to decommissioned infrastructure, creating a risk of **subdomain takeover**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/projects/domains"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Domains\n3. Review the DNS configuration status for each domain\n4. Update DNS records at your domain registrar to match the values shown in the Vercel dashboard\n5. Wait for DNS propagation (typically 24-48 hours)",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Update DNS records at your domain registrar to correctly point to Vercel. Use a CNAME record for subdomains or an A record for apex domains. Verify the configuration in the Vercel dashboard after making changes.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/domain_dns_properly_configured"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"domain_verified",
|
||||
"domain_ssl_certificate_valid"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.domain.domain_client import domain_client
|
||||
|
||||
|
||||
class domain_dns_properly_configured(Check):
|
||||
"""Check if domains have DNS properly configured.
|
||||
|
||||
This class verifies whether each Vercel domain has its DNS records
|
||||
properly configured to point to Vercel's infrastructure.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Domain DNS Configuration check.
|
||||
|
||||
Iterates over all domains and checks if DNS is properly configured.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each domain.
|
||||
"""
|
||||
findings = []
|
||||
for domain in domain_client.domains.values():
|
||||
report = CheckReportVercel(
|
||||
metadata=self.metadata(),
|
||||
resource=domain,
|
||||
resource_name=domain.name,
|
||||
resource_id=domain.id or domain.name,
|
||||
)
|
||||
|
||||
if domain.configured:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Domain {domain.name} has DNS properly configured."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Domain {domain.name} does not have DNS properly configured. "
|
||||
f"The domain may not be resolving to Vercel's infrastructure."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,124 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.vercel.lib.service.service import VercelService
|
||||
|
||||
|
||||
class Domain(VercelService):
|
||||
"""Retrieve Vercel domains with DNS and SSL information."""
|
||||
|
||||
def __init__(self, provider):
|
||||
super().__init__("Domain", provider)
|
||||
self.domains: dict[str, VercelDomain] = {}
|
||||
self._list_domains()
|
||||
self.__threading_call__(self._fetch_dns_records, list(self.domains.values()))
|
||||
self.__threading_call__(
|
||||
self._fetch_ssl_certificate, list(self.domains.values())
|
||||
)
|
||||
|
||||
def _list_domains(self):
|
||||
"""List all domains."""
|
||||
try:
|
||||
raw_domains = self._paginate("/v5/domains", "domains")
|
||||
|
||||
seen_names: set[str] = set()
|
||||
|
||||
for domain in raw_domains:
|
||||
domain_name = domain.get("name", "")
|
||||
if not domain_name or domain_name in seen_names:
|
||||
continue
|
||||
seen_names.add(domain_name)
|
||||
|
||||
self.domains[domain_name] = VercelDomain(
|
||||
name=domain_name,
|
||||
id=domain.get("id", domain_name),
|
||||
apex_name=domain.get("apexName"),
|
||||
verified=domain.get("verified", False),
|
||||
configured=(
|
||||
domain.get("configured", False)
|
||||
if "configured" in domain
|
||||
else domain.get("verified", False)
|
||||
),
|
||||
redirect=domain.get("redirect"),
|
||||
team_id=self.provider.session.team_id,
|
||||
)
|
||||
|
||||
logger.info(f"Domain - Found {len(self.domains)} domain(s)")
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Domain - Error listing domains: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _fetch_dns_records(self, domain: "VercelDomain"):
|
||||
"""Fetch DNS records for a single domain."""
|
||||
try:
|
||||
data = self._get(f"/v4/domains/{domain.name}/records")
|
||||
if data and "records" in data:
|
||||
domain.dns_records = data["records"]
|
||||
logger.debug(
|
||||
f"Domain - Fetched {len(domain.dns_records)} DNS records for {domain.name}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Domain - Error fetching DNS records for {domain.name}: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _fetch_ssl_certificate(self, domain: "VercelDomain"):
|
||||
"""Fetch SSL certificate for a domain via the certs endpoint."""
|
||||
try:
|
||||
data = self._get(f"/v8/certs/{domain.name}")
|
||||
if data:
|
||||
expires_at_ms = data.get("expiresAt")
|
||||
created_at_ms = data.get("createdAt")
|
||||
domain.ssl_certificate = VercelSSLCertificate(
|
||||
id=data.get("id", ""),
|
||||
created_at=(
|
||||
datetime.fromtimestamp(created_at_ms / 1000, tz=timezone.utc)
|
||||
if created_at_ms
|
||||
else None
|
||||
),
|
||||
expires_at=(
|
||||
datetime.fromtimestamp(expires_at_ms / 1000, tz=timezone.utc)
|
||||
if expires_at_ms
|
||||
else None
|
||||
),
|
||||
auto_renew=data.get("autoRenew", False),
|
||||
cns=data.get("cns", []),
|
||||
)
|
||||
logger.debug(f"Domain - Fetched SSL certificate for {domain.name}")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Domain - Error fetching SSL certificate for {domain.name}: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class VercelSSLCertificate(BaseModel):
|
||||
"""Vercel SSL certificate representation."""
|
||||
|
||||
id: str = ""
|
||||
created_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
auto_renew: bool = False
|
||||
cns: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VercelDomain(BaseModel):
|
||||
"""Vercel domain representation."""
|
||||
|
||||
name: str
|
||||
id: str = ""
|
||||
apex_name: Optional[str] = None
|
||||
verified: bool = False
|
||||
configured: bool = False
|
||||
ssl_certificate: Optional[VercelSSLCertificate] = None
|
||||
redirect: Optional[str] = None
|
||||
dns_records: list[dict] = Field(default_factory=list)
|
||||
team_id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "domain_ssl_certificate_valid",
|
||||
"CheckTitle": "Vercel domains have a valid, non-expired SSL certificate",
|
||||
"CheckType": [],
|
||||
"ServiceName": "domain",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Vercel domains** are assessed for **SSL certificate validity** including provisioning, expiration, and upcoming expiry. Vercel automatically provisions and renews SSL certificates for properly configured domains. A missing, expired, or soon-to-expire certificate indicates a configuration issue that may leave traffic unencrypted.",
|
||||
"Risk": "Without an **SSL certificate**, traffic between users and the domain is transmitted in **plain text**. This exposes sensitive data such as authentication tokens, form submissions, and personal information to interception via **man-in-the-middle attacks**. Search engines also penalize non-HTTPS sites, reducing visibility.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/security/encryption"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Domains\n3. Verify the domain's DNS records point to Vercel correctly\n4. Vercel will automatically provision an SSL certificate once DNS is properly configured\n5. If issues persist, remove and re-add the domain",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Ensure domain DNS records are properly configured to point to Vercel. Once DNS is validated, Vercel automatically provisions and renews SSL/TLS certificates. Check the domain configuration in the Vercel dashboard if the certificate is not being issued.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/domain_ssl_certificate_valid"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"encryption"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"domain_verified",
|
||||
"domain_dns_properly_configured"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel, Severity
|
||||
from prowler.providers.vercel.services.domain.domain_client import domain_client
|
||||
|
||||
|
||||
class domain_ssl_certificate_valid(Check):
|
||||
"""Check if domains have a valid, non-expired SSL certificate.
|
||||
|
||||
This class verifies whether each Vercel domain has an SSL certificate
|
||||
that is provisioned, not expired, and not about to expire. The
|
||||
expiration threshold is configurable via ``days_to_expire_threshold``
|
||||
in audit_config (default: 7 days).
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Domain SSL Certificate check.
|
||||
|
||||
Iterates over all domains and checks SSL certificate presence and
|
||||
expiration status.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each domain.
|
||||
"""
|
||||
findings = []
|
||||
now = datetime.now(timezone.utc)
|
||||
days_to_expire_threshold = domain_client.audit_config.get(
|
||||
"days_to_expire_threshold", 7
|
||||
)
|
||||
|
||||
for domain in domain_client.domains.values():
|
||||
report = CheckReportVercel(
|
||||
metadata=self.metadata(),
|
||||
resource=domain,
|
||||
resource_name=domain.name,
|
||||
resource_id=domain.id or domain.name,
|
||||
)
|
||||
|
||||
if domain.ssl_certificate is None:
|
||||
report.status = "FAIL"
|
||||
report.check_metadata.Severity = Severity.high
|
||||
report.status_extended = f"Domain {domain.name} does not have an SSL certificate provisioned."
|
||||
elif (
|
||||
domain.ssl_certificate.expires_at is not None
|
||||
and domain.ssl_certificate.expires_at <= now
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.check_metadata.Severity = Severity.critical
|
||||
report.status_extended = (
|
||||
f"Domain {domain.name} has an SSL certificate that expired "
|
||||
f"on {domain.ssl_certificate.expires_at.strftime('%Y-%m-%d %H:%M UTC')}."
|
||||
)
|
||||
elif domain.ssl_certificate.expires_at is not None:
|
||||
days_left = (domain.ssl_certificate.expires_at - now).days
|
||||
if days_left <= days_to_expire_threshold:
|
||||
report.status = "FAIL"
|
||||
report.check_metadata.Severity = Severity.high
|
||||
report.status_extended = (
|
||||
f"Domain {domain.name} has an SSL certificate expiring "
|
||||
f"in {days_left} days "
|
||||
f"on {domain.ssl_certificate.expires_at.strftime('%Y-%m-%d %H:%M UTC')}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Domain {domain.name} has a valid SSL certificate expiring "
|
||||
f"on {domain.ssl_certificate.expires_at.strftime('%Y-%m-%d %H:%M UTC')}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Domain {domain.name} has an SSL certificate provisioned."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "domain_verified",
|
||||
"CheckTitle": "Vercel domains are verified",
|
||||
"CheckType": [],
|
||||
"ServiceName": "domain",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Vercel domains** are assessed for **ownership verification** status. Unverified domains may not serve traffic correctly and could indicate a pending or incomplete domain setup. Domain verification confirms that the domain owner has authorized Vercel to manage the domain.",
|
||||
"Risk": "**Unverified domains** may fail to resolve or serve content, causing **downtime** for users. An unverified domain could also indicate a stale or orphaned configuration, or a domain that was added but never properly transferred, creating potential for **domain takeover** if the ownership verification is left incomplete.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/projects/domains"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Domains\n3. For any unverified domain, follow the verification steps shown\n4. Add the required DNS records (CNAME or A record) at your domain registrar\n5. Wait for DNS propagation and verify the domain",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Complete domain verification by configuring the required DNS records at your domain registrar. Remove any domains that are no longer needed to reduce the attack surface. Regularly audit domain configurations to ensure all domains remain verified.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/domain_verified"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"domain_dns_properly_configured",
|
||||
"domain_ssl_certificate_valid"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.domain.domain_client import domain_client
|
||||
|
||||
|
||||
class domain_verified(Check):
|
||||
"""Check if domains have been verified by Vercel.
|
||||
|
||||
This class verifies whether each Vercel domain has passed ownership
|
||||
verification. Unverified domains may not function correctly and could
|
||||
indicate domain misconfiguration or hijacking attempts.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Domain Verified check.
|
||||
|
||||
Iterates over all domains and checks if each is verified.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each domain.
|
||||
"""
|
||||
findings = []
|
||||
for domain in domain_client.domains.values():
|
||||
report = CheckReportVercel(
|
||||
metadata=self.metadata(),
|
||||
resource=domain,
|
||||
resource_name=domain.name,
|
||||
resource_id=domain.id or domain.name,
|
||||
)
|
||||
|
||||
if domain.verified:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Domain {domain.name} is verified."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Domain {domain.name} is not verified. "
|
||||
f"The domain may not be serving traffic correctly."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_auto_expose_system_env_disabled",
|
||||
"CheckTitle": "Vercel project has automatic exposure of system environment variables disabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Vercel projects** are assessed for **automatic system environment variable exposure** (`VERCEL_URL`, `VERCEL_ENV`, `VERCEL_GIT_COMMIT_SHA`). When enabled, these variables are injected into every deployment and may be accessible in client-side JavaScript bundles if not handled carefully, leaking internal infrastructure details.",
|
||||
"Risk": "Automatically exposed **system environment variables** can reveal deployment URLs, Git metadata, environment names, and other internal details. If these values are inadvertently included in **client-side bundles**, attackers can use them to map infrastructure, identify staging environments, or craft targeted attacks against specific deployment instances.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/projects/environment-variables/system-environment-variables"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Environment Variables\n3. Locate the 'Automatically expose System Environment Variables' toggle\n4. Disable the toggle\n5. Manually add only the specific system variables your application needs",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Disable automatic exposure of system environment variables and explicitly define only the variables required by your application. This follows the principle of least privilege and reduces the risk of leaking internal infrastructure details through client-side code.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_auto_expose_system_env_disabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
class project_auto_expose_system_env_disabled(Check):
|
||||
"""Check if automatic exposure of system environment variables is disabled.
|
||||
|
||||
This class verifies whether each Vercel project has the automatic exposure
|
||||
of system environment variables disabled to prevent information leakage.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Project Auto Expose System Env check.
|
||||
|
||||
Iterates over all projects and checks if automatic exposure of system
|
||||
environment variables is disabled.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
if not project.auto_expose_system_envs:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} does not automatically expose "
|
||||
f"system environment variables to the build process."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} automatically exposes system "
|
||||
f"environment variables to the build process."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.project.project_service import Project
|
||||
|
||||
project_client = Project(Provider.get_global_provider())
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_deployment_protection_enabled",
|
||||
"CheckTitle": "Vercel project has deployment protection enabled on preview deployments",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Vercel projects** are assessed for **deployment protection** configuration, which restricts access to preview deployments by requiring authentication before visitors can view them. When disabled, anyone with the preview URL can access in-progress or staging versions of the application, potentially exposing unreleased features, debug information, or internal endpoints.",
|
||||
"Risk": "Without **deployment protection** on preview deployments, any person who obtains or guesses a preview URL can view **unreleased application code**, test data, or internal API endpoints. This increases the attack surface and may leak sensitive business logic or credentials embedded in preview builds.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/security/deployment-protection"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Scroll to Deployment Protection\n4. Under Preview deployments, select 'Standard Protection' or 'Vercel Authentication'\n5. Click Save",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable deployment protection on preview deployments to require authentication before visitors can access preview URLs. Use 'Standard Protection' for Vercel Authentication or configure trusted IP ranges for more granular control.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_deployment_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"project_production_deployment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
class project_deployment_protection_enabled(Check):
|
||||
"""Check if deployment protection is enabled on preview deployments.
|
||||
|
||||
This class verifies whether each Vercel project has deployment protection
|
||||
configured for preview deployments to prevent unauthorized access.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Project Deployment Protection check.
|
||||
|
||||
Iterates over all projects and checks if deployment protection is enabled
|
||||
on preview deployments.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
if (
|
||||
project.deployment_protection is not None
|
||||
and project.deployment_protection.level != "none"
|
||||
):
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has deployment protection enabled "
|
||||
f"with level '{project.deployment_protection.level}' on preview deployments."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} does not have deployment protection "
|
||||
f"enabled on preview deployments."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_directory_listing_disabled",
|
||||
"CheckTitle": "Vercel project has directory listing disabled",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Vercel projects** are assessed for **directory listing** configuration. When enabled, this feature allows visitors to browse the file structure of a deployment when no index file is present in a directory, potentially exposing source files, configuration files, and other assets that should not be publicly accessible.",
|
||||
"Risk": "Enabled **directory listing** allows attackers to enumerate the file structure of the deployment, potentially discovering backup files, configuration files, source maps, or other **sensitive assets**. This information disclosure can be leveraged to identify attack vectors or access files that were not intended to be public.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/projects/project-configuration"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Locate the 'Directory Listing' option\n4. Ensure it is disabled\n5. Click Save",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Disable directory listing to prevent visitors from browsing the file structure of your deployments. Ensure that all directories either contain an index file or return a 404 response when accessed directly.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_directory_listing_disabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
class project_directory_listing_disabled(Check):
|
||||
"""Check if directory listing is disabled for the project.
|
||||
|
||||
This class verifies whether each Vercel project has directory listing
|
||||
disabled to prevent exposure of the project's file structure.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Project Directory Listing check.
|
||||
|
||||
Iterates over all projects and checks if directory listing is disabled.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
if not project.directory_listing:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has directory listing disabled."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has directory listing enabled, "
|
||||
f"which may expose the project's file structure to visitors."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_environment_no_overly_broad_target",
|
||||
"CheckTitle": "Vercel project has no environment variables targeting all three environments",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Vercel project environment variables** are assessed for **overly broad targeting** by checking whether any variable targets all three environments (production, preview, development) simultaneously, which violates the principle of least privilege.",
|
||||
"Risk": "Environment variables targeting **all environments** share the same values across production, preview, and development, increasing **blast radius** if credentials are compromised. Production secrets are exposed to weaker environments, making it harder to isolate and track unauthorized changes.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/environment-variables"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Navigate to your Vercel project dashboard\n2. Go to Settings > Environment Variables\n3. Identify variables that target all three environments (Production, Preview, Development)\n4. Edit each variable to target only the specific environments where it is needed\n5. Create separate variables with environment-specific values where different credentials are needed per environment",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Follow the **principle of least privilege** for environment variable targeting.\n- Assign each variable to only the environments where it is actually needed\n- Use different credentials for production, preview, and development environments\n- Non-sensitive configuration (e.g. feature flags, public URLs) may be acceptable in multiple environments but should still be reviewed\n- Regularly audit environment variable targets to prevent scope creep",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_environment_no_overly_broad_target"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"project_environment_production_vars_not_in_preview"
|
||||
],
|
||||
"Notes": "This check flags any variable targeting all three environments regardless of its type. Even non-sensitive configuration shared across all environments may indicate a lack of environment-specific configuration management."
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
ALL_ENVIRONMENTS = {"production", "preview", "development"}
|
||||
|
||||
|
||||
class project_environment_no_overly_broad_target(Check):
|
||||
"""Check that no environment variables target all three environments simultaneously.
|
||||
|
||||
This class verifies that environment variables are not configured to target
|
||||
production, preview, and development environments at the same time, which
|
||||
violates the principle of least privilege and may expose production secrets
|
||||
to development and preview contexts.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the no-overly-broad-target check.
|
||||
|
||||
Iterates over all projects and inspects each environment variable,
|
||||
flagging any that target all three environments (production, preview,
|
||||
and development) simultaneously.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
broad_keys = []
|
||||
for env_var in project.environment_variables:
|
||||
targets = {t.lower() for t in env_var.target}
|
||||
if ALL_ENVIRONMENTS.issubset(targets):
|
||||
broad_keys.append(env_var.key)
|
||||
|
||||
if broad_keys:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has {len(broad_keys)} environment "
|
||||
f"variable(s) targeting all three environments: "
|
||||
f"{', '.join(broad_keys)}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has no environment variables targeting "
|
||||
f"all three environments simultaneously."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_environment_no_secrets_in_plain_type",
|
||||
"CheckTitle": "Vercel project has no secret-like environment variables stored as plain text",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Vercel project environment variables** are assessed for **secret exposure** by checking whether variables with secret-like name suffixes (`*_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, `*_API_KEY`, `*_PRIVATE_KEY`) are stored using the `plain` type, which makes their values readable.",
|
||||
"Risk": "Secrets stored as **plain text** environment variables are visible to all team members with project access and may appear in API responses. Plaintext secrets can be read through the Vercel dashboard or API, enabling **unauthorized modification** of connected services or disruption of integrations.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/environment-variables"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Navigate to your Vercel project dashboard\n2. Go to Settings > Environment Variables\n3. Identify any variables ending in _KEY, _SECRET, _TOKEN, _PASSWORD, _API_KEY, or _PRIVATE_KEY that are stored as 'Plain'\n4. Delete the plain-text variable\n5. Re-create it using the 'Sensitive' type to ensure the value is encrypted and write-only",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use the **Sensitive** type for all environment variables that contain secrets, keys, tokens, or passwords.\n- Sensitive variables are never exposed in the dashboard or API responses after creation\n- Rotate all credentials that were previously stored as plain text\n- Implement naming conventions that make it easy to identify secret variables",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_environment_no_secrets_in_plain_type"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"project_environment_production_vars_not_in_preview"
|
||||
],
|
||||
"Notes": "This check uses suffix-based matching on variable names (_KEY, _SECRET, _TOKEN, _PASSWORD, _API_KEY, _PRIVATE_KEY) to identify likely secrets."
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
DEFAULT_SECRET_SUFFIXES = [
|
||||
"_KEY",
|
||||
"_SECRET",
|
||||
"_TOKEN",
|
||||
"_PASSWORD",
|
||||
"_API_KEY",
|
||||
"_PRIVATE_KEY",
|
||||
]
|
||||
|
||||
|
||||
class project_environment_no_secrets_in_plain_type(Check):
|
||||
"""Check that no environment variables with secret-like name suffixes are stored as plain text.
|
||||
|
||||
This class verifies that environment variables whose names end with
|
||||
configurable secret suffixes are not stored with the "plain" type,
|
||||
which makes their values readable in the dashboard and API responses.
|
||||
The suffix list is configurable via ``secret_suffixes`` in audit_config.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the no-secrets-in-plain-type check.
|
||||
|
||||
Iterates over all projects and inspects each environment variable,
|
||||
flagging any variable whose name ends with a known secret suffix and
|
||||
is stored as "plain" type.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
secret_suffixes = project_client.audit_config.get(
|
||||
"secret_suffixes", DEFAULT_SECRET_SUFFIXES
|
||||
)
|
||||
# Normalize to uppercase tuples for efficient endswith matching
|
||||
secret_suffixes_upper = tuple(s.upper() for s in secret_suffixes)
|
||||
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
plain_secret_keys = []
|
||||
for env_var in project.environment_variables:
|
||||
upper_key = env_var.key.upper()
|
||||
if upper_key.endswith(secret_suffixes_upper):
|
||||
if env_var.type == "plain":
|
||||
plain_secret_keys.append(env_var.key)
|
||||
|
||||
if plain_secret_keys:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has {len(plain_secret_keys)} secret-like "
|
||||
f"environment variable(s) stored as plain text: "
|
||||
f"{', '.join(plain_secret_keys)}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has no secret-like environment variables "
|
||||
f"stored as plain text."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_environment_production_vars_not_in_preview",
|
||||
"CheckTitle": "Vercel sensitive production environment variables do not target preview",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Vercel project environment variables** are assessed for **environment separation** by checking whether sensitive variables (type `secret` or `encrypted`) that target the `production` environment also target `preview`, which could expose production credentials to untrusted preview builds.",
|
||||
"Risk": "Preview deployments are often triggered by **pull requests**, including those from external contributors or forks. Sharing **production secrets** with preview environments can lead to credential theft. Production API keys and database credentials could be exfiltrated by malicious code in preview builds and used to modify or disrupt live services.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/environment-variables"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Navigate to your Vercel project dashboard\n2. Go to Settings > Environment Variables\n3. Identify sensitive variables (type Secret or Encrypted) that target both Production and Preview\n4. Edit each variable to remove the Preview target\n5. If preview builds require credentials, create separate variables with limited-scope preview-only credentials",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Maintain strict **environment separation** between production and preview deployments.\n- Use dedicated, limited-scope credentials for preview environments\n- Never share production database credentials, API keys, or signing keys with preview builds\n- Enable Vercel's deployment protection features to further restrict access to preview deployments\n- Regularly audit which environment variables target multiple environments",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_environment_production_vars_not_in_preview"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"project_environment_no_secrets_in_plain_type",
|
||||
"project_environment_no_overly_broad_target"
|
||||
],
|
||||
"Notes": "This check only inspects variables with type 'secret' or 'encrypted' since these are the ones most likely to contain actual credentials. Plain-text variables with sensitive names should be caught by the project_environment_no_secrets_in_plain_type check."
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
SENSITIVE_TYPES = {"secret", "encrypted"}
|
||||
|
||||
|
||||
class project_environment_production_vars_not_in_preview(Check):
|
||||
"""Check that sensitive production environment variables do not also target preview.
|
||||
|
||||
This class verifies that environment variables using "secret" or "encrypted"
|
||||
types that target "production" do not simultaneously target "preview"
|
||||
deployments, which could expose production credentials to untrusted code
|
||||
running in preview builds from pull requests.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the production-vars-not-in-preview check.
|
||||
|
||||
Iterates over all projects, inspects each environment variable with a
|
||||
sensitive type (secret or encrypted), and flags any that target both
|
||||
"production" and "preview" environments.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
leaking_keys = []
|
||||
for env_var in project.environment_variables:
|
||||
if env_var.type in SENSITIVE_TYPES:
|
||||
targets = {t.lower() for t in env_var.target}
|
||||
if "production" in targets and "preview" in targets:
|
||||
leaking_keys.append(env_var.key)
|
||||
|
||||
if leaking_keys:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has {len(leaking_keys)} sensitive "
|
||||
f"production environment variable(s) also targeting preview: "
|
||||
f"{', '.join(leaking_keys)}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has no sensitive production environment "
|
||||
f"variables leaking to preview deployments."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_git_fork_protection_enabled",
|
||||
"CheckTitle": "Vercel project has Git fork protection enabled to prevent untrusted forks from accessing secrets",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "devops",
|
||||
"Description": "**Vercel projects** are assessed for **Git fork protection** configuration, which controls whether pull requests from forked repositories can trigger deployments and access environment variables. When disabled, anyone who forks a public repository can submit a pull request that triggers a Vercel build with access to the project's environment variables, including secrets and API keys.",
|
||||
"Risk": "Without **Git fork protection**, an attacker can fork a public repository, modify the build process to **exfiltrate environment variables** (API keys, database credentials, third-party tokens), and submit a pull request. The Vercel build triggered by the PR would execute the attacker's code with access to the project's secrets, leading to **credential theft** and potential full system compromise.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/security/deployment-protection/managing-deployment-protection#git-fork-protection"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Scroll to the 'Git Fork Protection' section\n4. Enable the option to require authorization for fork pull requests\n5. Click Save",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable Git fork protection to require explicit authorization before pull requests from forked repositories can trigger deployments. This prevents untrusted contributors from accessing environment variables and secrets through the build process. For open-source projects, review fork PRs manually before allowing builds.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_git_fork_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
class project_git_fork_protection_enabled(Check):
|
||||
"""Check if Git fork protection is enabled for the project.
|
||||
|
||||
This class verifies whether each Vercel project has Git fork protection
|
||||
enabled to prevent untrusted forks from accessing environment variables
|
||||
and triggering deployments.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Project Git Fork Protection check.
|
||||
|
||||
Iterates over all projects and checks if Git fork protection is enabled.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
if project.git_fork_protection:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has Git fork protection enabled, "
|
||||
f"preventing untrusted forks from accessing secrets."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} does not have Git fork protection "
|
||||
f"enabled, allowing forks to access environment variables "
|
||||
f"and trigger deployments."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_password_protection_enabled",
|
||||
"CheckTitle": "Vercel project has password protection configured for deployments",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Vercel projects** are assessed for **password protection** configuration, which adds a shared-password gate in front of deployments requiring visitors to enter a password before they can access the application. This provides an additional layer of access control beyond Vercel Authentication, useful for sharing preview deployments with external stakeholders who do not have Vercel accounts.",
|
||||
"Risk": "Without **password protection**, deployments are accessible to anyone who has the URL. For projects that contain pre-release features, client work, or sensitive content, this means **unauthorized individuals** can view and interact with the application without any authentication barrier.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/security/deployment-protection/methods-to-protect-deployments/password-protection"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Scroll to the 'Password Protection' section\n4. Enable Password Protection and set a strong shared password\n5. Click Save\n6. Share the password only with authorized stakeholders",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable password protection to add a shared-password gate to your deployments. This is especially recommended for preview deployments shared with external clients or stakeholders who do not have Vercel accounts. Combine with Vercel Authentication for defense-in-depth.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_password_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"project_deployment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
class project_password_protection_enabled(Check):
|
||||
"""Check if password protection is enabled for the project.
|
||||
|
||||
This class verifies whether each Vercel project has password protection
|
||||
configured to restrict access to deployments with a shared password.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Project Password Protection check.
|
||||
|
||||
Iterates over all projects and checks if password protection is configured.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
if (
|
||||
project.password_protection
|
||||
and isinstance(project.password_protection, dict)
|
||||
and project.password_protection.get("deploymentType")
|
||||
):
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has password protection configured "
|
||||
f"to restrict access to deployments."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} does not have password protection "
|
||||
f"configured for deployments."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_production_deployment_protection_enabled",
|
||||
"CheckTitle": "Vercel project has deployment protection enabled on production deployments",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Vercel projects** are assessed for **production deployment protection** configuration, which restricts access to the live production deployment by requiring Vercel Authentication or other access controls. When enabled, visitors must authenticate before accessing the production URL, adding a layer of defense for internal applications or projects that should not be publicly accessible.",
|
||||
"Risk": "Without **production deployment protection**, the live production deployment is fully accessible to anyone on the internet. For internal tools, admin panels, or pre-launch applications this means **unauthorized users** can interact with production systems, potentially exploiting vulnerabilities, accessing sensitive data, or abusing application functionality.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/security/deployment-protection"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Scroll to Deployment Protection\n4. Under Production deployments, select 'Standard Protection' or 'Vercel Authentication'\n5. Click Save",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable deployment protection on production deployments for applications that should not be publicly accessible. This is critical for internal tools, admin dashboards, and pre-launch applications where unauthorized access could lead to data exposure or system compromise.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_production_deployment_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"project_deployment_protection_enabled"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
class project_production_deployment_protection_enabled(Check):
|
||||
"""Check if deployment protection is enabled on production deployments.
|
||||
|
||||
This class verifies whether each Vercel project has deployment protection
|
||||
configured for production deployments to prevent unauthorized public access.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Project Production Deployment Protection check.
|
||||
|
||||
Iterates over all projects and checks if deployment protection is enabled
|
||||
on production deployments.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
if (
|
||||
project.production_deployment_protection is not None
|
||||
and project.production_deployment_protection.level != "none"
|
||||
):
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has production deployment protection "
|
||||
f"enabled with level '{project.production_deployment_protection.level}'."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} does not have deployment protection "
|
||||
f"enabled on production deployments."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,163 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.vercel.lib.service.service import VercelService
|
||||
|
||||
|
||||
class Project(VercelService):
|
||||
"""Retrieve Vercel projects with security-relevant settings and environment variables."""
|
||||
|
||||
def __init__(self, provider):
|
||||
super().__init__("Project", provider)
|
||||
self.projects: dict[str, VercelProject] = {}
|
||||
self._list_projects()
|
||||
self.__threading_call__(self._fetch_env_vars, list(self.projects.values()))
|
||||
|
||||
def _list_projects(self):
|
||||
"""List all projects, optionally filtered by --project argument."""
|
||||
try:
|
||||
raw_projects = self._paginate("/v9/projects", "projects")
|
||||
|
||||
filter_projects = self.provider.filter_projects
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
for proj in raw_projects:
|
||||
project_id = proj.get("id")
|
||||
if not project_id or project_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(project_id)
|
||||
|
||||
project_name = proj.get("name", "")
|
||||
|
||||
# Apply project filter if specified
|
||||
if filter_projects and (
|
||||
project_id not in filter_projects
|
||||
and project_name not in filter_projects
|
||||
):
|
||||
continue
|
||||
|
||||
# Parse deployment protection
|
||||
dp = None
|
||||
dp_raw = proj.get("deploymentProtection", {}) or {}
|
||||
|
||||
preview_dp = dp_raw.get("deploymentType", "none")
|
||||
if preview_dp and preview_dp != "none":
|
||||
dp = DeploymentProtectionConfig(level=preview_dp)
|
||||
|
||||
prod_dp = None
|
||||
prod_raw = dp_raw.get("prod", {}) or {}
|
||||
prod_level = prod_raw.get("deploymentType", "none")
|
||||
if prod_level and prod_level != "none":
|
||||
prod_dp = DeploymentProtectionConfig(level=prod_level)
|
||||
|
||||
# Parse password protection
|
||||
pwd_protection = proj.get("passwordProtection")
|
||||
|
||||
self.projects[project_id] = VercelProject(
|
||||
id=project_id,
|
||||
name=project_name,
|
||||
team_id=proj.get("accountId") or self.provider.session.team_id,
|
||||
framework=proj.get("framework"),
|
||||
node_version=proj.get("nodeVersion"),
|
||||
auto_expose_system_envs=proj.get("autoExposeSystemEnvs", False),
|
||||
directory_listing=proj.get("directoryListing", False),
|
||||
skew_protection=(
|
||||
proj.get("skewProtection") == "enabled"
|
||||
if isinstance(proj.get("skewProtection"), str)
|
||||
else bool(proj.get("skewProtection", False))
|
||||
),
|
||||
deployment_protection=dp,
|
||||
production_deployment_protection=prod_dp,
|
||||
password_protection=pwd_protection,
|
||||
git_fork_protection=proj.get("gitForkProtection", True),
|
||||
git_repository=proj.get("link"),
|
||||
secure_compute=proj.get("secureCompute"),
|
||||
)
|
||||
|
||||
logger.info(f"Project - Found {len(self.projects)} project(s)")
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Project - Error listing projects: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _fetch_env_vars(self, project: "VercelProject"):
|
||||
"""Fetch environment variables for a single project."""
|
||||
try:
|
||||
env_data = self._paginate(f"/v9/projects/{project.id}/env", "envs")
|
||||
|
||||
env_vars = []
|
||||
for env in env_data:
|
||||
env_vars.append(
|
||||
VercelEnvironmentVariable(
|
||||
id=env.get("id", ""),
|
||||
key=env.get("key", ""),
|
||||
type=env.get("type", "plain"),
|
||||
target=env.get("target", []),
|
||||
project_id=project.id,
|
||||
project_name=project.name,
|
||||
git_branch=env.get("gitBranch"),
|
||||
created_at=(
|
||||
datetime.fromtimestamp(
|
||||
env["createdAt"] / 1000, tz=timezone.utc
|
||||
)
|
||||
if env.get("createdAt")
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
project.environment_variables = env_vars
|
||||
logger.debug(
|
||||
f"Project - Fetched {len(env_vars)} env vars for project {project.name}"
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Project - Error fetching env vars for {project.name}: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class DeploymentProtectionConfig(BaseModel):
|
||||
"""Per-environment deployment protection settings."""
|
||||
|
||||
level: str = "none" # "standard" | "all" | "none"
|
||||
method: Optional[str] = None
|
||||
|
||||
|
||||
class VercelEnvironmentVariable(BaseModel):
|
||||
"""Vercel project environment variable."""
|
||||
|
||||
id: str
|
||||
key: str
|
||||
type: str = "plain" # "encrypted" | "plain" | "secret" | "system"
|
||||
target: list[str] = Field(default_factory=list)
|
||||
project_id: str = ""
|
||||
project_name: Optional[str] = None
|
||||
git_branch: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class VercelProject(BaseModel):
|
||||
"""Vercel project representation used across checks."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
team_id: Optional[str] = None
|
||||
framework: Optional[str] = None
|
||||
node_version: Optional[str] = None
|
||||
auto_expose_system_envs: bool = False
|
||||
directory_listing: bool = False
|
||||
skew_protection: bool = False
|
||||
deployment_protection: Optional[DeploymentProtectionConfig] = None
|
||||
production_deployment_protection: Optional[DeploymentProtectionConfig] = None
|
||||
password_protection: Optional[dict] = None
|
||||
git_fork_protection: bool = True
|
||||
git_repository: Optional[dict] = None
|
||||
secure_compute: Optional[dict] = None
|
||||
environment_variables: list[VercelEnvironmentVariable] = Field(default_factory=list)
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "project_skew_protection_enabled",
|
||||
"CheckTitle": "Vercel project has skew protection enabled to prevent version mismatches during deployments",
|
||||
"CheckType": [],
|
||||
"ServiceName": "project",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Vercel projects** are assessed for **skew protection**, which ensures clients always communicate with the correct deployment version during rollouts. Without it, clients may fetch assets or call APIs against a different version than the one that served the initial page, causing hydration errors or broken functionality.",
|
||||
"Risk": "Without **skew protection**, users may experience **version mismatches** during deployment rollouts where the HTML is served from one deployment version but subsequent client-side navigation or API calls hit a newer version. This can cause broken user interfaces, failed client-side transitions, or **data corruption** from incompatible API contract changes.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/deployments/skew-protection"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Locate the 'Skew Protection' section\n4. Enable Skew Protection\n5. Click Save",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable skew protection to ensure that all client requests during a deployment rollout are routed to the same deployment version that served the initial page. This prevents version mismatch errors and ensures a consistent user experience during deployments.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/project_skew_protection_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
class project_skew_protection_enabled(Check):
|
||||
"""Check if skew protection is enabled for the project.
|
||||
|
||||
This class verifies whether each Vercel project has skew protection enabled
|
||||
to ensure clients are served consistent deployment versions during rollouts.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Project Skew Protection check.
|
||||
|
||||
Iterates over all projects and checks if skew protection is enabled.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
if project.skew_protection:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} has skew protection enabled, "
|
||||
f"ensuring consistent deployment versions during rollouts."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} does not have skew protection enabled, "
|
||||
f"which may cause version mismatches during deployments."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.security.security_service import Security
|
||||
|
||||
security_client = Security(Provider.get_global_provider())
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user