diff --git a/.github/labeler.yml b/.github/labeler.yml index 9a6b7262d0..49ec92e0d5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/.github/test-impact.yml b/.github/test-impact.yml index 3fab82c80b..7c290eeaa9 100644 --- a/.github/test-impact.yml +++ b/.github/test-impact.yml @@ -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 # ============================================ diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 6e514916b1..d0da08cc55 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -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' diff --git a/README.md b/README.md index b8ca63f4c8..87ca1f152a 100644 --- a/README.md +++ b/README.md @@ -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] diff --git a/docs/docs.json b/docs/docs.json index 6fcd387a07..21f65e027f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" + ] } ] }, diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 05de41b157..766f60e3c0 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -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 | diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index 923bef7ce1..7f96891cbb 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -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 @@ -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" + ``` diff --git a/docs/user-guide/providers/vercel/authentication.mdx b/docs/user-guide/providers/vercel/authentication.mdx new file mode 100644 index 0000000000..32cb0256f9 --- /dev/null +++ b/docs/user-guide/providers/vercel/authentication.mdx @@ -0,0 +1,137 @@ +--- +title: "Vercel Authentication in Prowler" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +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. + + +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. + + +| 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". + + ![Vercel Account Settings](/user-guide/providers/vercel/images/vercel-account-settings.png) + +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**. + + ![Create Vercel Token](/user-guide/providers/vercel/images/vercel-create-token.png) + +8. Copy the token immediately. + + +Vercel only displays the token once. Copy it immediately and store it securely. If lost, a new token must be created. + + +### 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. + + ![Vercel Team ID](/user-guide/providers/vercel/images/vercel-team-id.png) + +### 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. diff --git a/docs/user-guide/providers/vercel/getting-started-vercel.mdx b/docs/user-guide/providers/vercel/getting-started-vercel.mdx new file mode 100644 index 0000000000..67d4853d18 --- /dev/null +++ b/docs/user-guide/providers/vercel/getting-started-vercel.mdx @@ -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 + + + +### 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 +``` + + +When no team is specified, Prowler auto-discovers all teams the authenticated user belongs to and scans each one. + + +#### 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 | diff --git a/docs/user-guide/providers/vercel/images/vercel-account-settings.png b/docs/user-guide/providers/vercel/images/vercel-account-settings.png new file mode 100644 index 0000000000..429c7a12f1 Binary files /dev/null and b/docs/user-guide/providers/vercel/images/vercel-account-settings.png differ diff --git a/docs/user-guide/providers/vercel/images/vercel-create-token.png b/docs/user-guide/providers/vercel/images/vercel-create-token.png new file mode 100644 index 0000000000..4aaf93da73 Binary files /dev/null and b/docs/user-guide/providers/vercel/images/vercel-create-token.png differ diff --git a/docs/user-guide/providers/vercel/images/vercel-team-id.png b/docs/user-guide/providers/vercel/images/vercel-team-id.png new file mode 100644 index 0000000000..c377439e7a Binary files /dev/null and b/docs/user-guide/providers/vercel/images/vercel-team-id.png differ diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index fd6dd121fe..99765e9fce 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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 diff --git a/prowler/__main__.py b/prowler/__main__.py index 42b0f1508f..8d4b3cb26e 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -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: diff --git a/prowler/config/config.py b/prowler/config/config.py index 2a6671915e..3339a0d1ea 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -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) diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index dcff0a6169..c645d6e25b 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -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" diff --git a/prowler/config/vercel_mutelist_example.yaml b/prowler/config/vercel_mutelist_example.yaml new file mode 100644 index 0000000000..ee6439b77f --- /dev/null +++ b/prowler/config/vercel_mutelist_example.yaml @@ -0,0 +1,50 @@ +### Account, Check and/or Region can be * to apply for all the cases. +### Account == +### 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" diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index 0b93a0e82e..0041d4fde2 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -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 diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 524248d26f..43d6018cbf 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -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: """ diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index e82ead1db9..47275b12cf 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -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 diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index a93d20f063..26fbe327ee 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -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" diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 4ffbf1f402..d547ea3b95 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -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""" +
  • + Team: {team.name} ({team.id}) +
  • """ + + credentials_items = """ +
  • + Authentication: API Token +
  • """ + + email = getattr(provider.identity, "email", None) + if email: + credentials_items += f""" +
  • + Email: {email} +
  • """ + + username = getattr(provider.identity, "username", None) + if username: + credentials_items += f""" +
  • + Username: {username} +
  • """ + + return f""" +
    +
    +
    + Vercel Assessment Summary +
    +
      {assessment_items} +
    +
    +
    +
    +
    +
    + Vercel Credentials +
    +
      {credentials_items} +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_assessment_summary(provider: Provider) -> str: """ diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index 82d3494a5e..1e2f6f2058 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -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: diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 5f50f53c81..635139117d 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -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): diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 8798179a8d..debc1c4ef2 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -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( diff --git a/prowler/providers/vercel/__init__.py b/prowler/providers/vercel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/exceptions/__init__.py b/prowler/providers/vercel/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/exceptions/exceptions.py b/prowler/providers/vercel/exceptions/exceptions.py new file mode 100644 index 0000000000..f876f778bb --- /dev/null +++ b/prowler/providers/vercel/exceptions/exceptions.py @@ -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 + ) diff --git a/prowler/providers/vercel/lib/__init__.py b/prowler/providers/vercel/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/lib/arguments/__init__.py b/prowler/providers/vercel/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/lib/arguments/arguments.py b/prowler/providers/vercel/lib/arguments/arguments.py new file mode 100644 index 0000000000..df179ff4d8 --- /dev/null +++ b/prowler/providers/vercel/lib/arguments/arguments.py @@ -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.", + ) diff --git a/prowler/providers/vercel/lib/mutelist/__init__.py b/prowler/providers/vercel/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/lib/mutelist/mutelist.py b/prowler/providers/vercel/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..38095a69d8 --- /dev/null +++ b/prowler/providers/vercel/lib/mutelist/mutelist.py @@ -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)), + ) diff --git a/prowler/providers/vercel/lib/service/__init__.py b/prowler/providers/vercel/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/lib/service/service.py b/prowler/providers/vercel/lib/service/service.py new file mode 100644 index 0000000000..aaf4d2625c --- /dev/null +++ b/prowler/providers/vercel/lib/service/service.py @@ -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 diff --git a/prowler/providers/vercel/models.py b/prowler/providers/vercel/models.py new file mode 100644 index 0000000000..5f0e207f19 --- /dev/null +++ b/prowler/providers/vercel/models.py @@ -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 diff --git a/prowler/providers/vercel/services/__init__.py b/prowler/providers/vercel/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/authentication/__init__.py b/prowler/providers/vercel/services/authentication/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/authentication/authentication_client.py b/prowler/providers/vercel/services/authentication/authentication_client.py new file mode 100644 index 0000000000..b5074a9f94 --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_client.py @@ -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()) diff --git a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/__init__.py b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json new file mode 100644 index 0000000000..d94e12f0b9 --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json @@ -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." +} diff --git a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.py b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.py new file mode 100644 index 0000000000..5b958ce39e --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.py @@ -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 diff --git a/prowler/providers/vercel/services/authentication/authentication_service.py b/prowler/providers/vercel/services/authentication/authentication_service.py new file mode 100644 index 0000000000..ef1ed5b2c7 --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_service.py @@ -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 diff --git a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/__init__.py b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json new file mode 100644 index 0000000000..dca48e73bf --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json @@ -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." +} diff --git a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.py b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.py new file mode 100644 index 0000000000..b3e9c3ec9e --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.py @@ -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 diff --git a/prowler/providers/vercel/services/deployment/__init__.py b/prowler/providers/vercel/services/deployment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/deployment/deployment_client.py b/prowler/providers/vercel/services/deployment/deployment_client.py new file mode 100644 index 0000000000..18c240a83b --- /dev/null +++ b/prowler/providers/vercel/services/deployment/deployment_client.py @@ -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()) diff --git a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/__init__.py b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json new file mode 100644 index 0000000000..8a7cc70d40 --- /dev/null +++ b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json @@ -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." +} diff --git a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.py b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.py new file mode 100644 index 0000000000..39b4d43904 --- /dev/null +++ b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.py @@ -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 diff --git a/prowler/providers/vercel/services/deployment/deployment_service.py b/prowler/providers/vercel/services/deployment/deployment_service.py new file mode 100644 index 0000000000..ec271086c9 --- /dev/null +++ b/prowler/providers/vercel/services/deployment/deployment_service.py @@ -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 diff --git a/prowler/providers/vercel/services/domain/__init__.py b/prowler/providers/vercel/services/domain/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/domain/domain_client.py b/prowler/providers/vercel/services/domain/domain_client.py new file mode 100644 index 0000000000..30479da773 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_client.py @@ -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()) diff --git a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/__init__.py b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json new file mode 100644 index 0000000000..f9104ee960 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.py b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.py new file mode 100644 index 0000000000..1e3f91c394 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.py @@ -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 diff --git a/prowler/providers/vercel/services/domain/domain_service.py b/prowler/providers/vercel/services/domain/domain_service.py new file mode 100644 index 0000000000..9b3dba644a --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_service.py @@ -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 diff --git a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/__init__.py b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json new file mode 100644 index 0000000000..f15892df61 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.py b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.py new file mode 100644 index 0000000000..f4e61e6675 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.py @@ -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 diff --git a/prowler/providers/vercel/services/domain/domain_verified/__init__.py b/prowler/providers/vercel/services/domain/domain_verified/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json new file mode 100644 index 0000000000..f520fb00d8 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/domain/domain_verified/domain_verified.py b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.py new file mode 100644 index 0000000000..b6589b873e --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.py @@ -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 diff --git a/prowler/providers/vercel/services/project/__init__.py b/prowler/providers/vercel/services/project/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/__init__.py b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json new file mode 100644 index 0000000000..bf7adc2832 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.py b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.py new file mode 100644 index 0000000000..e2d12ceab9 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.py @@ -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 diff --git a/prowler/providers/vercel/services/project/project_client.py b/prowler/providers/vercel/services/project/project_client.py new file mode 100644 index 0000000000..571180d3e7 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_client.py @@ -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()) diff --git a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json new file mode 100644 index 0000000000..c704b42784 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.py b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.py new file mode 100644 index 0000000000..b9966b27db --- /dev/null +++ b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.py @@ -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 diff --git a/prowler/providers/vercel/services/project/project_directory_listing_disabled/__init__.py b/prowler/providers/vercel/services/project/project_directory_listing_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json new file mode 100644 index 0000000000..b9de49aa0e --- /dev/null +++ b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.py b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.py new file mode 100644 index 0000000000..57405fe43e --- /dev/null +++ b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.py @@ -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 diff --git a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/__init__.py b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json new file mode 100644 index 0000000000..2467c1b104 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json @@ -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." +} diff --git a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.py b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.py new file mode 100644 index 0000000000..d62d88d965 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.py @@ -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 diff --git a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/__init__.py b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json new file mode 100644 index 0000000000..f8d5621914 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json @@ -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." +} diff --git a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.py b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.py new file mode 100644 index 0000000000..904e592575 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.py @@ -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 diff --git a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/__init__.py b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json new file mode 100644 index 0000000000..55f468fa8a --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json @@ -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." +} diff --git a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.py b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.py new file mode 100644 index 0000000000..ac84a08887 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.py @@ -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 diff --git a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json new file mode 100644 index 0000000000..744a227d61 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.py b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.py new file mode 100644 index 0000000000..b3b05e58c2 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.py @@ -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 diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_password_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json new file mode 100644 index 0000000000..58e3e46e41 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py new file mode 100644 index 0000000000..86c0778408 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py @@ -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 diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json new file mode 100644 index 0000000000..213bc51d07 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py new file mode 100644 index 0000000000..ccbfd15325 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py @@ -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 diff --git a/prowler/providers/vercel/services/project/project_service.py b/prowler/providers/vercel/services/project/project_service.py new file mode 100644 index 0000000000..9b3b24859d --- /dev/null +++ b/prowler/providers/vercel/services/project/project_service.py @@ -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) diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_skew_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json new file mode 100644 index 0000000000..b73aaa6991 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json @@ -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": "" +} diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py new file mode 100644 index 0000000000..f4e878ba5c --- /dev/null +++ b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py @@ -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 diff --git a/prowler/providers/vercel/services/security/__init__.py b/prowler/providers/vercel/services/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_client.py b/prowler/providers/vercel/services/security/security_client.py new file mode 100644 index 0000000000..f55b2177b0 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_client.py @@ -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()) diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/__init__.py b/prowler/providers/vercel/services/security/security_custom_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json new file mode 100644 index 0000000000..c3f986b173 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "security_custom_rules_configured", + "CheckTitle": "Vercel project has custom firewall rules configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **custom firewall rule** configuration. Custom rules allow fine-grained control over traffic based on request attributes such as path, headers, user agent, and geographic location, providing application-specific protection beyond managed rulesets.", + "Risk": "Without **custom firewall rules**, the application lacks application-specific traffic filtering. Generic managed rulesets may not cover all threat vectors unique to the application. Custom rules are needed to block **known attack patterns**, restrict access to sensitive paths, and enforce application-level security policies.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall/custom-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Add custom rules to protect sensitive endpoints\n4. Configure conditions based on path, headers, geographic location, or other request attributes\n5. Set appropriate actions (block, challenge, or allow) for each rule", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure custom firewall rules to protect application-specific endpoints and enforce security policies. Focus on protecting admin panels, API routes, authentication endpoints, and any paths that handle sensitive data.", + "Url": "https://hub.prowler.com/checks/vercel/security_custom_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_waf_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py new file mode 100644 index 0000000000..6ec7f8834f --- /dev/null +++ b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py @@ -0,0 +1,42 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_custom_rules_configured(Check): + """Check if custom firewall rules are configured for each project. + + This class verifies whether each Vercel project has at least one + custom firewall rule configured for application-specific protection. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Custom Rules Configuration check. + + Iterates over all firewall configurations and checks if at least + one custom rule is present. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.custom_rules: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has {len(config.custom_rules)} custom firewall rule(s) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have any custom firewall rules configured." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/__init__.py b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json new file mode 100644 index 0000000000..cc7712d4ec --- /dev/null +++ b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "security_ip_blocking_rules_configured", + "CheckTitle": "Vercel project has IP blocking rules configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **IP blocking rule** configuration. IP blocking rules allow denying access from known malicious IP addresses or ranges, reducing the attack surface and preventing traffic from untrusted sources.", + "Risk": "Without **IP blocking rules**, all traffic is accepted regardless of source IP. Known malicious IPs, abuse networks, and previously identified attackers can freely access the application. This increases the risk of **automated scanning**, credential stuffing, and targeted attacks from known threat sources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Add a new IP blocking rule\n4. Specify the IP addresses or CIDR ranges to block\n5. Review firewall logs to identify malicious IPs that should be blocked", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure IP blocking rules to deny traffic from known malicious sources. Maintain a blocklist of IPs identified through security monitoring, threat intelligence feeds, or incident investigation. Regularly review and update the blocklist.", + "Url": "https://hub.prowler.com/checks/vercel/security_ip_blocking_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_waf_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py new file mode 100644 index 0000000000..0b89c8d111 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py @@ -0,0 +1,43 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_ip_blocking_rules_configured(Check): + """Check if IP blocking rules are configured for each project. + + This class verifies whether each Vercel project has at least one IP + blocking rule configured to restrict access from known malicious + IP addresses or ranges. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel IP Blocking Rules Configuration check. + + Iterates over all firewall configurations and checks if at least + one IP blocking rule is present. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.ip_blocking_rules: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has {len(config.ip_blocking_rules)} IP blocking rule(s) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have any IP blocking rules configured." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/__init__.py b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json new file mode 100644 index 0000000000..95d7db6d20 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "security_managed_rulesets_enabled", + "CheckTitle": "Vercel project has managed WAF rulesets enabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **managed WAF ruleset** enablement. Managed rulesets are curated by Vercel and provide protection against known attack patterns including **OWASP Top 10** threats. This feature requires an Enterprise plan and reports MANUAL status when unavailable.", + "Risk": "Without **managed rulesets** enabled, the firewall lacks curated protection rules against well-known attack patterns. The application relies solely on custom rules, which may miss **new or evolving threats** that managed rulesets are designed to detect and block automatically.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall/managed-rulesets" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Enable managed rulesets from the available options\n4. Review and configure ruleset sensitivity levels\n5. Note: This feature requires an Enterprise plan", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable managed WAF rulesets to benefit from Vercel-curated protection against common attack patterns. If you are on a plan that does not support managed rulesets, consider upgrading to the Enterprise plan for enhanced security features.", + "Url": "https://hub.prowler.com/checks/vercel/security_managed_rulesets_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_waf_enabled" + ], + "Notes": "This check is plan-gated. If the Vercel API returns a 403 for managed rulesets, the check reports MANUAL status indicating that an Enterprise plan is required." +} diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py new file mode 100644 index 0000000000..ead4623f4c --- /dev/null +++ b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py @@ -0,0 +1,52 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_managed_rulesets_enabled(Check): + """Check if managed WAF rulesets are enabled for each project. + + This class verifies whether each Vercel project has managed rulesets + enabled. Managed rulesets provide curated protection rules maintained + by Vercel against known attack patterns. This feature is plan-gated + and may not be available on all plans. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Managed Rulesets Enabled check. + + Iterates over all firewall configurations and checks if managed + rulesets are enabled. Reports MANUAL status when the feature is + not available due to plan limitations. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.managed_rulesets is None: + report.status = "MANUAL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"could not be assessed for managed rulesets. " + f"Enterprise plan required to access this feature." + ) + elif config.managed_rulesets: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has managed WAF rulesets enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have managed WAF rulesets enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/__init__.py b/prowler/providers/vercel/services/security/security_rate_limiting_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json new file mode 100644 index 0000000000..28a9c44d5f --- /dev/null +++ b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "security_rate_limiting_configured", + "CheckTitle": "Vercel project has rate limiting rules configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **rate limiting rule** configuration. Rate limiting protects applications from abuse, brute-force attacks, and DDoS attempts by restricting the number of requests from a single source within a given time window.", + "Risk": "Without **rate limiting**, the application is vulnerable to **brute-force attacks** on authentication endpoints, API abuse, resource exhaustion, and denial-of-service attacks. Attackers can overwhelm the application with excessive requests, degrading performance for legitimate users or exploiting endpoints without throttling.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Add a new rate limiting rule\n4. Configure the request threshold, time window, and action (challenge or block)\n5. Apply the rule to appropriate paths (e.g., /api/*, /login)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure rate limiting rules to protect critical endpoints such as authentication, API routes, and form submissions. Start with conservative thresholds and adjust based on traffic patterns to avoid blocking legitimate users.", + "Url": "https://hub.prowler.com/checks/vercel/security_rate_limiting_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_waf_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py new file mode 100644 index 0000000000..3e6419666c --- /dev/null +++ b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py @@ -0,0 +1,42 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_rate_limiting_configured(Check): + """Check if rate limiting rules are configured for each project. + + This class verifies whether each Vercel project has at least one rate + limiting rule configured to protect against abuse and DDoS attacks. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Rate Limiting Configuration check. + + Iterates over all firewall configurations and checks if at least + one rate limiting rule is present. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.rate_limiting_rules: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has {len(config.rate_limiting_rules)} rate limiting rule(s) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have any rate limiting rules configured." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/security/security_service.py b/prowler/providers/vercel/services/security/security_service.py new file mode 100644 index 0000000000..eadac856b9 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_service.py @@ -0,0 +1,127 @@ +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 Security(VercelService): + """Retrieve Vercel WAF/Firewall configuration per project.""" + + def __init__(self, provider): + super().__init__("Security", provider) + self.firewall_configs: dict[str, VercelFirewallConfig] = {} + + # We need project IDs to fetch firewall configs + # Import project_client to get project list + from prowler.providers.vercel.services.project.project_client import ( + project_client, + ) + + self.__threading_call__( + self._fetch_firewall_config, list(project_client.projects.values()) + ) + + def _fetch_firewall_config(self, project): + """Fetch WAF/Firewall config for a single project.""" + try: + data = self._get( + "/v1/security/firewall/config", + params={"projectId": project.id}, + ) + + if data is None: + # 403 — plan limitation, store with managed_rulesets=None + self.firewall_configs[project.id] = VercelFirewallConfig( + project_id=project.id, + project_name=project.name, + team_id=project.team_id, + firewall_enabled=False, + managed_rulesets=None, + name=project.name, + id=project.id, + ) + return + + # Parse firewall config + fw = data.get("firewallConfig", data) if isinstance(data, dict) else {} + + # Determine if firewall is enabled + rules = fw.get("rules", []) or [] + managed = fw.get("managedRules", fw.get("managedRulesets")) + custom_rules = [] + ip_blocking = [] + rate_limiting = [] + + for rule in rules: + rule_action = rule.get("action", {}) + action_type = ( + rule_action.get("type", "") + if isinstance(rule_action, dict) + else str(rule_action) + ) + + if action_type == "rate_limit" or rule.get("rateLimit"): + rate_limiting.append(rule) + elif action_type in ("deny", "block") and self._is_ip_rule(rule): + ip_blocking.append(rule) + else: + custom_rules.append(rule) + + firewall_enabled = bool(rules) or bool(managed) + + self.firewall_configs[project.id] = VercelFirewallConfig( + project_id=project.id, + project_name=project.name, + team_id=project.team_id, + firewall_enabled=firewall_enabled, + managed_rulesets=managed if managed is not None else {}, + custom_rules=custom_rules, + ip_blocking_rules=ip_blocking, + rate_limiting_rules=rate_limiting, + name=project.name, + id=project.id, + ) + + logger.debug( + f"Security - Loaded firewall config for {project.name}: " + f"enabled={firewall_enabled}, rules={len(rules)}" + ) + + except Exception as error: + logger.error( + f"Security - Error fetching firewall config for {project.name}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + @staticmethod + def _is_ip_rule(rule: dict) -> bool: + """Check if a rule is an IP blocking rule based on conditions.""" + conditions = rule.get("conditionGroup", []) + for group in conditions: + for condition in group.get("conditions", []): + if condition.get("type") == "ip_address" or condition.get("op") in ( + "inc", + "eq", + ): + prop = condition.get("prop", "") + if "ip" in prop.lower(): + return True + return False + + +class VercelFirewallConfig(BaseModel): + """Vercel WAF/Firewall configuration per project.""" + + project_id: str + project_name: Optional[str] = None + team_id: Optional[str] = None + firewall_enabled: bool = False + managed_rulesets: Optional[dict] = None # None means plan-gated (403) + custom_rules: list[dict] = Field(default_factory=list) + ip_blocking_rules: list[dict] = Field(default_factory=list) + rate_limiting_rules: list[dict] = Field(default_factory=list) + # Provide name/id for CheckReportVercel + name: str = "" + id: str = "" diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/__init__.py b/prowler/providers/vercel/services/security/security_waf_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json new file mode 100644 index 0000000000..c758c82f18 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "security_waf_enabled", + "CheckTitle": "Vercel project has the Web Application Firewall enabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **Web Application Firewall (WAF)** enablement. The WAF provides protection against common web attacks including **SQL injection**, **cross-site scripting (XSS)**, and other OWASP Top 10 threats.", + "Risk": "Without the **Web Application Firewall** enabled, the application is directly exposed to common web attacks including **SQL injection**, **cross-site scripting**, request smuggling, and other exploits. Attackers can exploit these vulnerabilities to steal data, deface the application, or gain unauthorized access.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security\n3. Enable the Web Application Firewall\n4. Configure appropriate rules and managed rulesets\n5. Monitor firewall logs for false positives and adjust rules accordingly", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the Vercel Web Application Firewall to protect your application against common web attacks. Start with managed rulesets for baseline protection and add custom rules as needed based on your application's threat model.", + "Url": "https://hub.prowler.com/checks/vercel/security_waf_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_managed_rulesets_enabled", + "security_custom_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py new file mode 100644 index 0000000000..82859a4f5c --- /dev/null +++ b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py @@ -0,0 +1,50 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_waf_enabled(Check): + """Check if the Vercel Web Application Firewall (WAF) is enabled. + + This class verifies whether each Vercel project has the Web Application + Firewall enabled to protect against common web attacks. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel WAF Enabled check. + + Iterates over all firewall configurations and checks if the WAF + is enabled for each project. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.managed_rulesets is None: + # 403 — plan limitation, cannot determine WAF status + report.status = "MANUAL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"could not be checked for WAF status due to plan limitations. " + f"Manual verification is required." + ) + elif config.firewall_enabled: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has the Web Application Firewall enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have the Web Application Firewall enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/__init__.py b/prowler/providers/vercel/services/team/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_client.py b/prowler/providers/vercel/services/team/team_client.py new file mode 100644 index 0000000000..0930aa074b --- /dev/null +++ b/prowler/providers/vercel/services/team/team_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.vercel.services.team.team_service import Team + +team_client = Team(Provider.get_global_provider()) diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/__init__.py b/prowler/providers/vercel/services/team/team_directory_sync_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json new file mode 100644 index 0000000000..37019b79da --- /dev/null +++ b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "team_directory_sync_enabled", + "CheckTitle": "Vercel team has directory sync (SCIM) enabled for automated provisioning", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Vercel team** is assessed for **directory sync (SCIM)** enablement. Directory sync automates user provisioning and deprovisioning by synchronizing team membership with an external identity provider, ensuring timely access revocation when employees leave.", + "Risk": "Without **directory sync**, user provisioning and deprovisioning must be managed manually, increasing the risk of **orphaned accounts** remaining active after employees leave or change roles. Manual processes are error-prone and may lead to unauthorized access persisting longer than intended.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles", + "https://vercel.com/docs/security/directory-sync" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Security section\n3. Configure directory sync (SCIM) with your identity provider\n4. Map identity provider groups to Vercel team roles\n5. Enable automated provisioning and deprovisioning", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable directory sync (SCIM) to automate user lifecycle management. This ensures that team membership stays synchronized with your identity provider, automatically provisioning new members and revoking access when employees leave or change roles.", + "Url": "https://hub.prowler.com/checks/vercel/team_directory_sync_enabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "team_saml_sso_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py new file mode 100644 index 0000000000..101922b6ed --- /dev/null +++ b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py @@ -0,0 +1,47 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_directory_sync_enabled(Check): + """Check if directory sync (SCIM) is enabled for the Vercel team. + + This class verifies whether the Vercel team has directory sync enabled, + allowing automated user provisioning and deprovisioning through an + identity provider. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team Directory Sync Enabled check. + + Iterates over all teams and checks if directory sync is enabled. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + if team.directory_sync_enabled: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has directory sync (SCIM) enabled " + f"for automated user provisioning." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Team {team.name} does not have directory sync (SCIM) enabled. " + f"User provisioning and deprovisioning must be managed manually." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_member_role_least_privilege/__init__.py b/prowler/providers/vercel/services/team/team_member_role_least_privilege/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json new file mode 100644 index 0000000000..0e4750d703 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "vercel", + "CheckID": "team_member_role_least_privilege", + "CheckTitle": "Vercel team follows least privilege with a limited number of owner roles", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Vercel team members** are assessed for **least privilege** by checking whether the proportion of members with the `OWNER` role exceeds a configurable percentage threshold (default: 20%) or a maximum owner count (default: 3). Both thresholds are configurable via audit_config. An excessive number of owners increases the attack surface and risk of accidental or malicious configuration changes.", + "Risk": "Having too many **team owners** increases the **blast radius** of compromised accounts and the risk of unauthorized changes to billing, security settings, and team membership. Each owner has full administrative privileges over the team.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Members section\n3. Review all members with the OWNER role\n4. Downgrade unnecessary owners to MEMBER, DEVELOPER, or VIEWER roles\n5. Ensure only essential personnel retain the OWNER role", + "Terraform": "" + }, + "Recommendation": { + "Text": "Limit the number of team owners to the minimum required for administration. Assign the least privileged role necessary for each member's responsibilities. Use MEMBER, DEVELOPER, or VIEWER roles for non-administrative team members.", + "Url": "https://hub.prowler.com/checks/vercel/team_member_role_least_privilege" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Thresholds are configurable via audit_config: max_owner_percentage (default: 20) and max_owners (default: 3). Small teams (<5 members) with at most 1 owner are always considered compliant." +} diff --git a/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.py b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.py new file mode 100644 index 0000000000..a2f122ffe8 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.py @@ -0,0 +1,88 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_member_role_least_privilege(Check): + """Check if the Vercel team follows least privilege for owner roles. + + This class verifies that the number of team members with the OWNER + role does not exceed configurable thresholds, following the principle + of least privilege. Both the percentage threshold and a maximum owner + count are configurable via audit_config. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team Member Role Least Privilege check. + + Iterates over all teams and checks if the proportion of OWNER + members is within acceptable bounds. Thresholds are configurable + via ``max_owner_percentage`` (default: 20) and ``max_owners`` + (default: 3) in audit_config. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + max_owner_percentage = team_client.audit_config.get("max_owner_percentage", 20) + max_owners = team_client.audit_config.get("max_owners", 3) + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + active_members = [m for m in team.members if m.status == "active"] + total_active = len(active_members) + + if total_active == 0: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has no active members to evaluate." + ) + findings.append(report) + continue + + owners = [m for m in active_members if m.role == "OWNER"] + owner_count = len(owners) + owner_percentage = (owner_count / total_active) * 100 + + if total_active < 5 and owner_count <= 1: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has {owner_count} owner(s) out of " + f"{total_active} active members. Small team with minimum " + f"required owner — least privilege threshold not applicable." + ) + elif owner_percentage <= max_owner_percentage and owner_count <= max_owners: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has {owner_count} owner(s) out of " + f"{total_active} active members ({owner_percentage:.0f}%), " + f"which is within the configured thresholds " + f"({max_owner_percentage}% / max {max_owners} owners)." + ) + else: + reasons = [] + if owner_percentage > max_owner_percentage: + reasons.append( + f"{owner_percentage:.0f}% exceeds the " + f"{max_owner_percentage}% threshold" + ) + if owner_count > max_owners: + reasons.append( + f"{owner_count} owners exceeds the " f"maximum of {max_owners}" + ) + report.status = "FAIL" + report.status_extended = ( + f"Team {team.name} has {owner_count} owner(s) out of " + f"{total_active} active members — " + f"{'; '.join(reasons)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_no_stale_invitations/__init__.py b/prowler/providers/vercel/services/team/team_no_stale_invitations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json new file mode 100644 index 0000000000..d05461c5c1 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "vercel", + "CheckID": "team_no_stale_invitations", + "CheckTitle": "Vercel team has no stale pending invitations older than 30 days", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "**Vercel team** is assessed for **stale invitations** by checking whether pending invitations have been outstanding for more than 30 days. Stale invitations may indicate abandoned onboarding processes or forgotten invitation links that could be exploited.", + "Risk": "**Stale pending invitations** represent unresolved access grants. If invitation links are intercepted or forwarded to unintended recipients, they could be used to gain **unauthorized access** to the team. Old invitations also indicate poor access lifecycle management.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Members section\n3. Review all pending invitations\n4. Revoke any invitations that have been pending for more than 30 days\n5. Re-invite members if still needed", + "Terraform": "" + }, + "Recommendation": { + "Text": "Regularly review and revoke stale team invitations. Establish a process to follow up on pending invitations within a reasonable timeframe and revoke those that are no longer needed to reduce the risk of unauthorized access.", + "Url": "https://hub.prowler.com/checks/vercel/team_no_stale_invitations" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.py b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.py new file mode 100644 index 0000000000..b57c0e37ab --- /dev/null +++ b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.py @@ -0,0 +1,70 @@ +from datetime import datetime, timezone +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_no_stale_invitations(Check): + """Check if the Vercel team has stale pending invitations. + + This class verifies that no team invitations have been pending for + more than 30 days, which may indicate abandoned or forgotten invitations + that should be revoked. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team No Stale Invitations check. + + Iterates over all teams and checks for pending invitations older + than 30 days. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + now = datetime.now(timezone.utc) + stale_threshold_days = team_client.audit_config.get( + "stale_invitation_threshold_days", 30 + ) + stale_invitations = [] + + for member in team.members: + if member.status != "invited": + continue + if member.created_at is None: + continue + + # Ensure created_at is timezone-aware for comparison + created_at = member.created_at + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + + age_days = (now - created_at).days + if age_days > stale_threshold_days: + stale_invitations.append(member) + + if not stale_invitations: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has no stale pending invitations " + f"older than {stale_threshold_days} days." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Team {team.name} has {len(stale_invitations)} stale " + f"pending invitation(s) older than {stale_threshold_days} days." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/__init__.py b/prowler/providers/vercel/services/team/team_saml_sso_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json new file mode 100644 index 0000000000..ebbe85ebc9 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "team_saml_sso_enabled", + "CheckTitle": "Vercel team has SAML SSO enabled for centralized identity management", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Vercel team** is assessed for **SAML single sign-on (SSO)** enablement. SAML SSO enables centralized identity management through an external identity provider, ensuring consistent authentication policies across the organization.", + "Risk": "Without **SAML SSO**, team members authenticate using individual Vercel credentials that are not centrally managed. This increases the risk of **credential sprawl**, inconsistent password policies, and inability to enforce organization-wide authentication controls such as **MFA**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles", + "https://vercel.com/docs/security/saml" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Security section\n3. Configure SAML SSO with your identity provider (Okta, Azure AD, etc.)\n4. Complete the SAML connection setup and verify it is linked", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable SAML SSO for the Vercel team to centralize authentication through your organization's identity provider. This ensures consistent security policies, simplifies user lifecycle management, and enables enforcement of MFA and other access controls.", + "Url": "https://hub.prowler.com/checks/vercel/team_saml_sso_enabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "team_saml_sso_enforced" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py new file mode 100644 index 0000000000..8a979ec66d --- /dev/null +++ b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py @@ -0,0 +1,45 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_saml_sso_enabled(Check): + """Check if SAML SSO is enabled for the Vercel team. + + This class verifies whether the Vercel team has SAML single sign-on + configured and enabled for centralized identity management. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team SAML SSO Enabled check. + + Iterates over all teams and checks if SAML SSO is enabled. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + if team.saml and team.saml.status == "enabled": + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has SAML SSO enabled" + f"{f' via {team.saml.provider}' if team.saml.provider else ''}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Team {team.name} does not have SAML SSO enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/__init__.py b/prowler/providers/vercel/services/team/team_saml_sso_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json new file mode 100644 index 0000000000..f4de8e7ebe --- /dev/null +++ b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "team_saml_sso_enforced", + "CheckTitle": "Vercel team enforces SAML SSO for all members", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Vercel team** is assessed for **SAML SSO enforcement** across all members. When enforced, all team members must authenticate through the configured identity provider, preventing the use of individual Vercel credentials.", + "Risk": "Without **SAML SSO enforcement**, team members can bypass centralized authentication and log in with individual credentials even when SAML is configured. This undermines **identity governance**, allows circumvention of MFA policies, and creates gaps in access auditing.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles", + "https://vercel.com/docs/security/saml" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Security section\n3. Ensure SAML SSO is configured and linked\n4. Enable SSO enforcement to require all members to authenticate via the identity provider", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce SAML SSO for all team members to ensure authentication is managed exclusively through your identity provider. This prevents credential bypass, enforces MFA policies, and provides centralized access control and audit capabilities.", + "Url": "https://hub.prowler.com/checks/vercel/team_saml_sso_enforced" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "team_saml_sso_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py new file mode 100644 index 0000000000..746ebba387 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py @@ -0,0 +1,50 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_saml_sso_enforced(Check): + """Check if SAML SSO enforcement is enabled for the Vercel team. + + This class verifies whether the Vercel team enforces SAML SSO, + requiring all members to authenticate through the identity provider. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team SAML SSO Enforced check. + + Iterates over all teams and checks if SAML SSO is enforced. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + if team.saml and team.saml.enforced: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} enforces SAML SSO for all members." + ) + else: + report.status = "FAIL" + if team.saml and team.saml.status == "enabled": + report.status_extended = ( + f"Team {team.name} has SAML SSO enabled but does not enforce it. " + f"Members can still authenticate without SSO." + ) + else: + report.status_extended = ( + f"Team {team.name} does not have SAML SSO enforced." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_service.py b/prowler/providers/vercel/services/team/team_service.py new file mode 100644 index 0000000000..916374ea87 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_service.py @@ -0,0 +1,157 @@ +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 Team(VercelService): + """Retrieve Vercel team configuration and membership.""" + + def __init__(self, provider): + super().__init__("Team", provider) + self.teams: dict[str, VercelTeam] = {} + self._fetch_team() + + def _fetch_team(self): + """Fetch team details and members for all teams in scope.""" + team_ids = self._all_team_ids + if not team_ids: + logger.info("Team - No teams found, skipping team checks") + return + + for team_id in team_ids: + self._fetch_single_team(team_id) + + def _fetch_single_team(self, team_id: str): + """Fetch details and members for a single team.""" + try: + # Fetch team details (pass teamId explicitly for auto-discovered teams) + team_data = self._get(f"/v2/teams/{team_id}", params={"teamId": team_id}) + if not team_data: + return + + # Parse SAML config + saml_config = None + saml_raw = team_data.get("saml", {}) or {} + if saml_raw: + # Vercel returns saml.connection object when SAML is configured + connection = saml_raw.get("connection", {}) or {} + saml_config = SAMLConfig( + status=( + "enabled" + if connection.get("status") == "linked" + or saml_raw.get("status") == "enabled" + else "disabled" + ), + enforced=team_data.get("saml", {}).get("enforced", False) + or team_data.get("enabledSSOEnforcement", False), + provider=connection.get("type"), + ) + + # Parse directory sync + dir_sync = False + # Check for SCIM configuration + if team_data.get("enabledScim") or team_data.get("scim"): + dir_sync = True + + created_at = None + if team_data.get("createdAt"): + created_at = datetime.fromtimestamp( + team_data["createdAt"] / 1000, tz=timezone.utc + ) + + team = VercelTeam( + id=team_data.get("id", team_id), + name=team_data.get("name", ""), + slug=team_data.get("slug", ""), + saml=saml_config, + directory_sync_enabled=dir_sync, + created_at=created_at, + ) + + # Fetch members + self._fetch_members(team) + + self.teams[team.id] = team + logger.info( + f"Team - Loaded team {team.name} with {len(team.members)} members" + ) + + except Exception as error: + logger.error( + f"Team - Error fetching team {team_id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_members(self, team: "VercelTeam"): + """Fetch all members for a team.""" + try: + raw_members = self._paginate( + f"/v2/teams/{team.id}/members", + "members", + params={"teamId": team.id}, + ) + + for member in raw_members: + joined_at = None + if member.get("joinedFrom", {}).get("commitAt"): + joined_at = datetime.fromtimestamp( + member["joinedFrom"]["commitAt"] / 1000, tz=timezone.utc + ) + elif member.get("createdAt"): + joined_at = datetime.fromtimestamp( + member["createdAt"] / 1000, tz=timezone.utc + ) + + created_at = None + if member.get("createdAt"): + created_at = datetime.fromtimestamp( + member["createdAt"] / 1000, tz=timezone.utc + ) + + team.members.append( + VercelTeamMember( + id=member.get("uid", member.get("id", "")), + email=member.get("email", ""), + role=member.get("role", "MEMBER"), + status=( + "invited" if member.get("confirmed") is False else "active" + ), + joined_at=joined_at, + created_at=created_at, + ) + ) + + except Exception as error: + logger.error( + f"Team - Error fetching members for {team.name}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class SAMLConfig(BaseModel): + status: str = "disabled" # "enabled" | "disabled" + enforced: bool = False + provider: Optional[str] = None + + +class VercelTeamMember(BaseModel): + id: str + email: str + role: str # "OWNER" | "MEMBER" | "DEVELOPER" | "VIEWER" | "BILLING" + status: str = "active" # "active" | "invited" + joined_at: Optional[datetime] = None + created_at: Optional[datetime] = None + + +class VercelTeam(BaseModel): + id: str + name: str + slug: str + saml: Optional[SAMLConfig] = None + directory_sync_enabled: bool = False + members: list[VercelTeamMember] = Field(default_factory=list) + created_at: Optional[datetime] = None diff --git a/prowler/providers/vercel/vercel_provider.py b/prowler/providers/vercel/vercel_provider.py new file mode 100644 index 0000000000..731adef6ed --- /dev/null +++ b/prowler/providers/vercel/vercel_provider.py @@ -0,0 +1,405 @@ +import os + +import requests +from colorama import Fore, Style + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.vercel.exceptions.exceptions import ( + VercelAuthenticationError, + VercelCredentialsError, + VercelIdentityError, + VercelInvalidTeamError, + VercelRateLimitError, + VercelSessionError, +) +from prowler.providers.vercel.lib.mutelist.mutelist import VercelMutelist +from prowler.providers.vercel.models import ( + VercelIdentityInfo, + VercelSession, + VercelTeamInfo, +) + + +class VercelProvider(Provider): + """Vercel provider.""" + + _type: str = "vercel" + _session: VercelSession + _identity: VercelIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: VercelMutelist + _filter_projects: set[str] | None + audit_metadata: Audit_Metadata + + def __init__( + self, + # Authentication credentials + api_token: str = None, + team_id: str = None, + # Scope + projects: list[str] | None = None, + # Provider configuration + config_path: str = None, + config_content: dict | None = None, + fixer_config: dict = {}, + mutelist_path: str = None, + mutelist_content: dict = None, + ): + logger.info("Instantiating Vercel provider...") + + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + self._session = VercelProvider.setup_session( + api_token=api_token, + team_id=team_id, + ) + + self._identity = VercelProvider.setup_identity(self._session) + + self._fixer_config = fixer_config + + if mutelist_content: + self._mutelist = VercelMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = VercelMutelist(mutelist_path=mutelist_path) + + # Store project filter for filtering resources across services + self._filter_projects = set(projects) if projects else None + + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def session(self): + return self._session + + @property + def identity(self): + return self._identity + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + @property + def mutelist(self) -> VercelMutelist: + return self._mutelist + + @property + def filter_projects(self) -> set[str] | None: + """Project filter from --project argument to filter scanned projects.""" + return self._filter_projects + + @staticmethod + def setup_session( + api_token: str = None, + team_id: str = None, + ) -> VercelSession: + """Initialize Vercel API session. + + Credentials can be provided as arguments (for API use) or read from + environment variables: + - VERCEL_TOKEN (API Bearer Token) + - VERCEL_TEAM (Team ID or slug, optional) + + Args: + api_token: Vercel API token (optional, falls back to VERCEL_TOKEN env var). + team_id: Vercel team ID or slug (optional, falls back to VERCEL_TEAM env var). + + Returns: + VercelSession: The initialized Vercel session. + + Raises: + VercelCredentialsError: If no credentials are provided. + VercelSessionError: If session setup fails. + """ + token = api_token or os.environ.get("VERCEL_TOKEN", "") + team = team_id or os.environ.get("VERCEL_TEAM", "") or None + + if not token: + raise VercelCredentialsError( + file=os.path.basename(__file__), + message="Vercel credentials not found. Provide an api_token or set the VERCEL_TOKEN environment variable.", + ) + + try: + http_session = requests.Session() + http_session.headers.update( + { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + ) + + return VercelSession( + token=token, + team_id=team, + http_session=http_session, + ) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise VercelSessionError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def setup_identity(session: VercelSession) -> VercelIdentityInfo: + """Fetch user and team metadata for Vercel. + + Args: + session: The Vercel session. + + Returns: + VercelIdentityInfo: The identity information. + + Raises: + VercelIdentityError: If identity setup fails. + """ + try: + http = session.http_session + params = {"teamId": session.team_id} if session.team_id else {} + + # Get user info + response = http.get( + f"{session.base_url}/v2/user", params=params, timeout=30 + ) + response.raise_for_status() + user_data = response.json().get("user", {}) + + user_id = user_data.get("id") + username = user_data.get("username") + email = user_data.get("email") + + # Get team info + team_info = None + all_teams = [] + + if session.team_id: + # Specific team requested — fetch just that one + params = {"teamId": session.team_id} + team_response = http.get( + f"{session.base_url}/v2/teams/{session.team_id}", + params=params, + timeout=30, + ) + if team_response.status_code == 200: + team_data = team_response.json() + team_info = VercelTeamInfo( + id=team_data.get("id", session.team_id), + name=team_data.get("name", ""), + slug=team_data.get("slug", ""), + ) + all_teams = [team_info] + elif team_response.status_code in (404, 403): + raise VercelInvalidTeamError( + file=os.path.basename(__file__), + message=f"Team '{session.team_id}' not found or not accessible.", + ) + else: + team_response.raise_for_status() + else: + # No team specified — auto-discover all teams the user belongs to + try: + teams_response = http.get( + f"{session.base_url}/v2/teams", + params={"limit": 100}, + timeout=30, + ) + if teams_response.status_code == 200: + teams_data = teams_response.json().get("teams", []) + for t in teams_data: + all_teams.append( + VercelTeamInfo( + id=t.get("id", ""), + name=t.get("name", ""), + slug=t.get("slug", ""), + ) + ) + if all_teams: + logger.info( + f"Auto-discovered {len(all_teams)} team(s): " + f"{', '.join(t.name for t in all_teams)}" + ) + except Exception as teams_error: + logger.warning(f"Could not auto-discover teams: {teams_error}") + + return VercelIdentityInfo( + user_id=user_id, + username=username, + email=email, + team=team_info, + teams=all_teams, + ) + except VercelInvalidTeamError: + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise VercelIdentityError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def validate_credentials(session: VercelSession) -> None: + """Validate Vercel credentials by calling GET /v2/user. + + Args: + session: The Vercel session to validate. + + Raises: + VercelAuthenticationError: If authentication fails. + VercelRateLimitError: If rate limited. + """ + try: + params = {} + if session.team_id: + params["teamId"] = session.team_id + response = session.http_session.get( + f"{session.base_url}/v2/user", params=params, timeout=30 + ) + + if response.status_code == 401: + raise VercelAuthenticationError( + file=os.path.basename(__file__), + message="Invalid or expired Vercel API token.", + ) + + if response.status_code == 403: + raise VercelAuthenticationError( + file=os.path.basename(__file__), + message="Insufficient permissions for the Vercel API token.", + ) + + if response.status_code == 429: + raise VercelRateLimitError( + file=os.path.basename(__file__), + ) + + response.raise_for_status() + + except (VercelAuthenticationError, VercelRateLimitError): + raise + except requests.exceptions.RequestException as error: + raise VercelAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + + def print_credentials(self) -> None: + report_title = ( + f"{Style.BRIGHT}Using the Vercel credentials below:{Style.RESET_ALL}" + ) + report_lines = [] + + report_lines.append(f"Authentication: {Fore.YELLOW}API Token{Style.RESET_ALL}") + + if self.identity.email: + report_lines.append( + f"Email: {Fore.YELLOW}{self.identity.email}{Style.RESET_ALL}" + ) + + if self.identity.username: + report_lines.append( + f"Username: {Fore.YELLOW}{self.identity.username}{Style.RESET_ALL}" + ) + + if self.identity.team: + report_lines.append( + f"Team: {Fore.YELLOW}{self.identity.team.name} ({self.identity.team.slug}){Style.RESET_ALL}" + ) + elif self.identity.teams: + team_names = ", ".join(f"{t.name} ({t.slug})" for t in self.identity.teams) + report_lines.append( + f"Scope: {Fore.YELLOW}Personal Account + {len(self.identity.teams)} team(s): {team_names}{Style.RESET_ALL}" + ) + else: + report_lines.append( + f"Scope: {Fore.YELLOW}Personal Account{Style.RESET_ALL}" + ) + + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + api_token: str = None, + team_id: str = None, + raise_on_exception: bool = True, + provider_id: str = None, + ) -> Connection: + """Test connection to Vercel. + + Credentials can be provided as arguments (for API use) or read from + environment variables (VERCEL_TOKEN, VERCEL_TEAM). + + Args: + api_token: Vercel API token (optional, falls back to env var). + team_id: Vercel team ID or slug (optional, falls back to env var). + raise_on_exception: Whether to raise or return errors. + provider_id: The provider ID. + + Returns: + Connection: Connection object with is_connected status. + """ + try: + session = VercelProvider.setup_session( + api_token=api_token, + team_id=team_id, + ) + VercelProvider.validate_credentials(session) + return Connection(is_connected=True) + + except ( + VercelCredentialsError, + VercelSessionError, + VercelAuthenticationError, + VercelRateLimitError, + ) as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise error + return Connection(is_connected=False, error=error) + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + formatted_error = VercelAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + if raise_on_exception: + raise formatted_error + return Connection(is_connected=False, error=formatted_error) + + def validate_arguments(self) -> None: + return None diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index b9d8a32dc9..e22ffac69b 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ prowler_command = "prowler" # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image} ..." def mock_get_available_providers(): diff --git a/tests/providers/vercel/lib/mutelist/fixtures/vercel_mutelist.yaml b/tests/providers/vercel/lib/mutelist/fixtures/vercel_mutelist.yaml new file mode 100644 index 0000000000..4cacaca5c4 --- /dev/null +++ b/tests/providers/vercel/lib/mutelist/fixtures/vercel_mutelist.yaml @@ -0,0 +1,9 @@ +Mutelist: + Accounts: + "team_test123": + Checks: + "project_deployment_protection_enabled": + Regions: + - "*" + Resources: + - "prj_test789" diff --git a/tests/providers/vercel/lib/mutelist/vercel_mutelist_test.py b/tests/providers/vercel/lib/mutelist/vercel_mutelist_test.py new file mode 100644 index 0000000000..c5cae1ba47 --- /dev/null +++ b/tests/providers/vercel/lib/mutelist/vercel_mutelist_test.py @@ -0,0 +1,93 @@ +from unittest.mock import MagicMock + +import yaml + +from prowler.providers.vercel.lib.mutelist.mutelist import VercelMutelist + +MUTELIST_FIXTURE_PATH = ( + "tests/providers/vercel/lib/mutelist/fixtures/vercel_mutelist.yaml" +) + + +class TestVercelMutelist: + def test_get_mutelist_file_from_local_file(self): + mutelist = VercelMutelist(mutelist_path=MUTELIST_FIXTURE_PATH) + + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + assert mutelist.mutelist == mutelist_fixture + assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH + + def test_get_mutelist_file_from_local_file_non_existent(self): + mutelist_path = "tests/providers/vercel/lib/mutelist/fixtures/not_present" + mutelist = VercelMutelist(mutelist_path=mutelist_path) + + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path == mutelist_path + + def test_validate_mutelist_not_valid_key(self): + mutelist_path = MUTELIST_FIXTURE_PATH + with open(mutelist_path) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"] + del mutelist_fixture["Accounts"] + + mutelist = VercelMutelist(mutelist_content=mutelist_fixture) + + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path is None + + def test_is_finding_muted(self): + mutelist_content = { + "Accounts": { + "team_test123": { + "Checks": { + "project_deployment_protection_enabled": { + "Regions": ["*"], + "Resources": ["prj_test789"], + } + } + } + } + } + + mutelist = VercelMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "project_deployment_protection_enabled" + finding.status = "FAIL" + finding.resource_id = "prj_test789" + finding.resource_name = "my-test-project" + finding.resource_tags = [] + + assert mutelist.is_finding_muted(finding, "team_test123") + + def test_is_finding_not_muted(self): + mutelist_content = { + "Accounts": { + "team_test123": { + "Checks": { + "project_deployment_protection_enabled": { + "Regions": ["*"], + "Resources": ["other-project-id"], + } + } + } + } + } + + mutelist = VercelMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "project_deployment_protection_enabled" + finding.status = "FAIL" + finding.resource_id = "prj_test789" + finding.resource_name = "my-test-project" + finding.resource_tags = [] + + assert not mutelist.is_finding_muted(finding, "team_test123") diff --git a/tests/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens_test.py b/tests/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens_test.py new file mode 100644 index 0000000000..873cf99a41 --- /dev/null +++ b/tests/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens_test.py @@ -0,0 +1,152 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.vercel.services.authentication.authentication_service import ( + VercelAuthToken, +) +from tests.providers.vercel.vercel_fixtures import set_mocked_vercel_provider + + +class Test_authentication_no_stale_tokens: + def test_no_tokens(self): + authentication_client = mock.MagicMock + authentication_client.audit_config = {"stale_token_threshold_days": 90} + authentication_client.tokens = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens import ( + authentication_no_stale_tokens, + ) + + check = authentication_no_stale_tokens() + result = check.execute() + assert len(result) == 0 + + def test_token_active_recently(self): + token_id = "tok_1" + token_name = "Recent Token" + active_at = datetime.now(timezone.utc) - timedelta(days=10) + authentication_client = mock.MagicMock + authentication_client.audit_config = {"stale_token_threshold_days": 90} + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + active_at=active_at, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens import ( + authentication_no_stale_tokens, + ) + + check = authentication_no_stale_tokens() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) was last active on {active_at.strftime('%Y-%m-%d %H:%M UTC')} (within the last 90 days)." + ) + assert result[0].team_id is None + + def test_token_stale_90_days(self): + token_id = "tok_2" + token_name = "Stale Token" + active_at = datetime.now(timezone.utc) - timedelta(days=120) + days_inactive = (datetime.now(timezone.utc) - active_at).days + authentication_client = mock.MagicMock + authentication_client.audit_config = {"stale_token_threshold_days": 90} + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + active_at=active_at, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens import ( + authentication_no_stale_tokens, + ) + + check = authentication_no_stale_tokens() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) has not been used for {days_inactive} days (last active: {active_at.strftime('%Y-%m-%d %H:%M UTC')}). Threshold is 90 days." + ) + assert result[0].team_id is None + + def test_token_no_activity(self): + token_id = "tok_3" + token_name = "Never Used Token" + authentication_client = mock.MagicMock + authentication_client.audit_config = {"stale_token_threshold_days": 90} + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + active_at=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens import ( + authentication_no_stale_tokens, + ) + + check = authentication_no_stale_tokens() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) has no recorded activity and is considered stale." + ) + assert result[0].team_id is None diff --git a/tests/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired_test.py b/tests/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired_test.py new file mode 100644 index 0000000000..47f19ad79e --- /dev/null +++ b/tests/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired_test.py @@ -0,0 +1,228 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.lib.check.models import Severity +from prowler.providers.vercel.services.authentication.authentication_service import ( + VercelAuthToken, +) +from tests.providers.vercel.vercel_fixtures import set_mocked_vercel_provider + + +class Test_authentication_token_not_expired: + def test_no_tokens(self): + authentication_client = mock.MagicMock + authentication_client.tokens = {} + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 0 + + def test_token_not_expired(self): + token_id = "tok_1" + token_name = "My Token" + expires_at = datetime.now(timezone.utc) + timedelta(days=30) + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=expires_at, + ) + } + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) is valid and expires on {expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + assert result[0].team_id is None + + def test_token_expired(self): + token_id = "tok_2" + token_name = "Old Token" + expires_at = datetime.now(timezone.utc) - timedelta(days=1) + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=expires_at, + ) + } + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) has expired on {expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + assert result[0].team_id is None + + def test_token_no_expiration(self): + token_id = "tok_3" + token_name = "Permanent Token" + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=None, + ) + } + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) does not have an expiration date set and is currently valid." + ) + assert result[0].team_id is None + + def test_token_about_to_expire(self): + """Token expiring within the default 7-day threshold gets FAIL with medium severity.""" + token_id = "tok_4" + token_name = "Expiring Soon Token" + expires_at = datetime.now(timezone.utc) + timedelta(days=3, hours=12) + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=expires_at, + ) + } + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.medium + assert "is about to expire in 3 days" in result[0].status_extended + assert result[0].team_id is None + + def test_token_about_to_expire_custom_threshold(self): + """Token expiring within a custom threshold (14 days) gets FAIL with medium severity.""" + token_id = "tok_5" + token_name = "Custom Threshold Token" + expires_at = datetime.now(timezone.utc) + timedelta(days=10, hours=12) + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=expires_at, + ) + } + authentication_client.audit_config = {"days_to_expire_threshold": 14} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.medium + assert "is about to expire in 10 days" in result[0].status_extended diff --git a/tests/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target_test.py b/tests/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target_test.py new file mode 100644 index 0000000000..3a92202ddd --- /dev/null +++ b/tests/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target_test.py @@ -0,0 +1,151 @@ +from unittest import mock + +from prowler.providers.vercel.services.deployment.deployment_service import ( + VercelDeployment, +) +from tests.providers.vercel.vercel_fixtures import ( + DEPLOYMENT_ID, + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_deployment_production_uses_stable_target: + def test_no_deployments(self): + deployment_client = mock.MagicMock + deployment_client.deployments = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target.deployment_client", + new=deployment_client, + ), + ): + from prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target import ( + deployment_production_uses_stable_target, + ) + + check = deployment_production_uses_stable_target() + result = check.execute() + assert len(result) == 0 + + def test_stable_branch(self): + deployment_client = mock.MagicMock + deployment_client.deployments = { + DEPLOYMENT_ID: VercelDeployment( + id=DEPLOYMENT_ID, + name="my-app-abc123", + target="production", + git_source={"branch": "main"}, + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ) + } + deployment_client.audit_config = {"stable_branches": ["main", "master"]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target.deployment_client", + new=deployment_client, + ), + ): + from prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target import ( + deployment_production_uses_stable_target, + ) + + check = deployment_production_uses_stable_target() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DEPLOYMENT_ID + assert result[0].resource_name == "my-app-abc123" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Production deployment my-app-abc123 ({DEPLOYMENT_ID}) is sourced from stable branch 'main'." + ) + assert result[0].team_id == TEAM_ID + + def test_non_stable_branch(self): + deployment_client = mock.MagicMock + deployment_client.deployments = { + DEPLOYMENT_ID: VercelDeployment( + id=DEPLOYMENT_ID, + name="my-app-abc123", + target="production", + git_source={"branch": "feature-xyz"}, + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ) + } + deployment_client.audit_config = {"stable_branches": ["main", "master"]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target.deployment_client", + new=deployment_client, + ), + ): + from prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target import ( + deployment_production_uses_stable_target, + ) + + check = deployment_production_uses_stable_target() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DEPLOYMENT_ID + assert result[0].resource_name == "my-app-abc123" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Production deployment my-app-abc123 ({DEPLOYMENT_ID}) is sourced from branch 'feature-xyz' instead of a configured stable branch (main, master)." + ) + assert result[0].team_id == TEAM_ID + + def test_non_production_skipped(self): + deployment_client = mock.MagicMock + deployment_client.deployments = { + DEPLOYMENT_ID: VercelDeployment( + id=DEPLOYMENT_ID, + name="my-app-abc123", + target="preview", + git_source={"branch": "feature-xyz"}, + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ) + } + deployment_client.audit_config = {"stable_branches": ["main", "master"]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target.deployment_client", + new=deployment_client, + ), + ): + from prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target import ( + deployment_production_uses_stable_target, + ) + + check = deployment_production_uses_stable_target() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured_test.py b/tests/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured_test.py new file mode 100644 index 0000000000..2562a27a14 --- /dev/null +++ b/tests/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured_test.py @@ -0,0 +1,108 @@ +from unittest import mock + +from prowler.providers.vercel.services.domain.domain_service import VercelDomain +from tests.providers.vercel.vercel_fixtures import ( + DOMAIN_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + +DOMAIN_ID = "dom_test001" + + +class Test_domain_dns_properly_configured: + def test_no_domains(self): + domain_client = mock.MagicMock + domain_client.domains = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured import ( + domain_dns_properly_configured, + ) + + check = domain_dns_properly_configured() + result = check.execute() + assert len(result) == 0 + + def test_configured(self): + domain_client = mock.MagicMock + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id=DOMAIN_ID, + configured=True, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured import ( + domain_dns_properly_configured, + ) + + check = domain_dns_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DOMAIN_ID + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Domain {DOMAIN_NAME} has DNS properly configured." + ) + assert result[0].team_id == TEAM_ID + + def test_not_configured(self): + domain_client = mock.MagicMock + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id=DOMAIN_ID, + configured=False, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured import ( + domain_dns_properly_configured, + ) + + check = domain_dns_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DOMAIN_ID + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Domain {DOMAIN_NAME} does not have DNS properly configured. The domain may not be resolving to Vercel's infrastructure." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid_test.py b/tests/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid_test.py new file mode 100644 index 0000000000..4b906731ab --- /dev/null +++ b/tests/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid_test.py @@ -0,0 +1,236 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.vercel.services.domain.domain_service import ( + VercelDomain, + VercelSSLCertificate, +) +from tests.providers.vercel.vercel_fixtures import ( + DOMAIN_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_domain_ssl_certificate_valid: + def test_no_domains(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 0 + + def test_ssl_valid_not_expiring_soon(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=VercelSSLCertificate( + id="cert_1", + expires_at=datetime.now(timezone.utc) + timedelta(days=90), + auto_renew=True, + ), + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "PASS" + assert "valid SSL certificate" in result[0].status_extended + assert result[0].team_id == TEAM_ID + + def test_ssl_missing(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=None, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Domain {DOMAIN_NAME} does not have an SSL certificate provisioned." + ) + assert result[0].team_id == TEAM_ID + + def test_ssl_expired(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=VercelSSLCertificate( + id="cert_1", + expires_at=datetime.now(timezone.utc) - timedelta(days=10), + auto_renew=False, + ), + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert "expired" in result[0].status_extended + assert result[0].team_id == TEAM_ID + + def test_ssl_expiring_soon(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=VercelSSLCertificate( + id="cert_1", + expires_at=datetime.now(timezone.utc) + timedelta(days=3), + auto_renew=False, + ), + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert "expiring" in result[0].status_extended + assert result[0].team_id == TEAM_ID + + def test_ssl_no_expiry_date(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=VercelSSLCertificate( + id="cert_1", + expires_at=None, + auto_renew=True, + ), + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "PASS" + assert "provisioned" in result[0].status_extended + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/domain/domain_verified/domain_verified_test.py b/tests/providers/vercel/services/domain/domain_verified/domain_verified_test.py new file mode 100644 index 0000000000..3634b69a07 --- /dev/null +++ b/tests/providers/vercel/services/domain/domain_verified/domain_verified_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.vercel.services.domain.domain_service import VercelDomain +from tests.providers.vercel.vercel_fixtures import ( + DOMAIN_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + +DOMAIN_ID = "dom_test001" + + +class Test_domain_verified: + def test_no_domains(self): + domain_client = mock.MagicMock + domain_client.domains = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_verified.domain_verified.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_verified.domain_verified import ( + domain_verified, + ) + + check = domain_verified() + result = check.execute() + assert len(result) == 0 + + def test_verified(self): + domain_client = mock.MagicMock + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id=DOMAIN_ID, + verified=True, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_verified.domain_verified.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_verified.domain_verified import ( + domain_verified, + ) + + check = domain_verified() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DOMAIN_ID + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "PASS" + assert result[0].status_extended == f"Domain {DOMAIN_NAME} is verified." + assert result[0].team_id == TEAM_ID + + def test_not_verified(self): + domain_client = mock.MagicMock + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id=DOMAIN_ID, + verified=False, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_verified.domain_verified.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_verified.domain_verified import ( + domain_verified, + ) + + check = domain_verified() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DOMAIN_ID + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Domain {DOMAIN_NAME} is not verified. The domain may not be serving traffic correctly." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled_test.py b/tests/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled_test.py new file mode 100644 index 0000000000..8738107467 --- /dev/null +++ b/tests/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_auto_expose_system_env_disabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled import ( + project_auto_expose_system_env_disabled, + ) + + check = project_auto_expose_system_env_disabled() + result = check.execute() + assert len(result) == 0 + + def test_auto_expose_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + auto_expose_system_envs=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled import ( + project_auto_expose_system_env_disabled, + ) + + check = project_auto_expose_system_env_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not automatically expose system environment variables to the build process." + ) + assert result[0].team_id == TEAM_ID + + def test_auto_expose_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + auto_expose_system_envs=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled import ( + project_auto_expose_system_env_disabled, + ) + + check = project_auto_expose_system_env_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} automatically exposes system environment variables to the build process." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled_test.py b/tests/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled_test.py new file mode 100644 index 0000000000..a37ce91048 --- /dev/null +++ b/tests/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled_test.py @@ -0,0 +1,151 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + DeploymentProtectionConfig, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_deployment_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled import ( + project_deployment_protection_enabled, + ) + + check = project_deployment_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_deployment_protection_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + deployment_protection=DeploymentProtectionConfig( + level="standard", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled import ( + project_deployment_protection_enabled, + ) + + check = project_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has deployment protection enabled with level 'standard' on preview deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_deployment_protection_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + deployment_protection=DeploymentProtectionConfig( + level="none", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled import ( + project_deployment_protection_enabled, + ) + + check = project_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have deployment protection enabled on preview deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_deployment_protection_none(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + deployment_protection=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled import ( + project_deployment_protection_enabled, + ) + + check = project_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have deployment protection enabled on preview deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled_test.py b/tests/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled_test.py new file mode 100644 index 0000000000..63bf279e06 --- /dev/null +++ b/tests/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_directory_listing_disabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled import ( + project_directory_listing_disabled, + ) + + check = project_directory_listing_disabled() + result = check.execute() + assert len(result) == 0 + + def test_listing_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + directory_listing=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled import ( + project_directory_listing_disabled, + ) + + check = project_directory_listing_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has directory listing disabled." + ) + assert result[0].team_id == TEAM_ID + + def test_listing_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + directory_listing=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled import ( + project_directory_listing_disabled, + ) + + check = project_directory_listing_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has directory listing enabled, which may expose the project's file structure to visitors." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target_test.py b/tests/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target_test.py new file mode 100644 index 0000000000..633d28a8e3 --- /dev/null +++ b/tests/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target_test.py @@ -0,0 +1,126 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + VercelEnvironmentVariable, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_environment_no_overly_broad_target: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target import ( + project_environment_no_overly_broad_target, + ) + + check = project_environment_no_overly_broad_target() + result = check.execute() + assert len(result) == 0 + + def test_no_broad_vars(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_001", + key="DATABASE_URL", + type="encrypted", + target=["production"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target import ( + project_environment_no_overly_broad_target, + ) + + check = project_environment_no_overly_broad_target() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no environment variables targeting all three environments simultaneously." + ) + assert result[0].team_id == TEAM_ID + + def test_var_targets_all_envs(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_002", + key="SHARED_VAR", + type="plain", + target=["production", "preview", "development"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target import ( + project_environment_no_overly_broad_target, + ) + + check = project_environment_no_overly_broad_target() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has 1 environment variable(s) targeting all three environments: SHARED_VAR." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type_test.py b/tests/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type_test.py new file mode 100644 index 0000000000..62de735634 --- /dev/null +++ b/tests/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type_test.py @@ -0,0 +1,172 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + VercelEnvironmentVariable, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_environment_no_secrets_in_plain_type: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.audit_config = {} + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type import ( + project_environment_no_secrets_in_plain_type, + ) + + check = project_environment_no_secrets_in_plain_type() + result = check.execute() + assert len(result) == 0 + + def test_no_secret_keys_plain(self): + project_client = mock.MagicMock + project_client.audit_config = {} + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_001", + key="DATABASE_PASSWORD", + type="encrypted", + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type import ( + project_environment_no_secrets_in_plain_type, + ) + + check = project_environment_no_secrets_in_plain_type() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no secret-like environment variables stored as plain text." + ) + assert result[0].team_id == TEAM_ID + + def test_secret_key_plain(self): + project_client = mock.MagicMock + project_client.audit_config = {} + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_002", + key="MY_API_KEY", + type="plain", + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type import ( + project_environment_no_secrets_in_plain_type, + ) + + check = project_environment_no_secrets_in_plain_type() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has 1 secret-like environment variable(s) stored as plain text: MY_API_KEY." + ) + assert result[0].team_id == TEAM_ID + + def test_non_secret_key_plain(self): + project_client = mock.MagicMock + project_client.audit_config = {} + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_003", + key="APP_NAME", + type="plain", + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type import ( + project_environment_no_secrets_in_plain_type, + ) + + check = project_environment_no_secrets_in_plain_type() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no secret-like environment variables stored as plain text." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview_test.py b/tests/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview_test.py new file mode 100644 index 0000000000..b8796ef19e --- /dev/null +++ b/tests/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview_test.py @@ -0,0 +1,171 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + VercelEnvironmentVariable, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_environment_production_vars_not_in_preview: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview import ( + project_environment_production_vars_not_in_preview, + ) + + check = project_environment_production_vars_not_in_preview() + result = check.execute() + assert len(result) == 0 + + def test_prod_only_secret(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_001", + key="DB_PASSWORD", + type="secret", + target=["production"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview import ( + project_environment_production_vars_not_in_preview, + ) + + check = project_environment_production_vars_not_in_preview() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no sensitive production environment variables leaking to preview deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_prod_and_preview_secret(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_002", + key="DB_PASSWORD", + type="secret", + target=["production", "preview"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview import ( + project_environment_production_vars_not_in_preview, + ) + + check = project_environment_production_vars_not_in_preview() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has 1 sensitive production environment variable(s) also targeting preview: DB_PASSWORD." + ) + assert result[0].team_id == TEAM_ID + + def test_prod_and_preview_plain(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_003", + key="APP_URL", + type="plain", + target=["production", "preview"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview import ( + project_environment_production_vars_not_in_preview, + ) + + check = project_environment_production_vars_not_in_preview() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no sensitive production environment variables leaking to preview deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled_test.py b/tests/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled_test.py new file mode 100644 index 0000000000..bd3dad4138 --- /dev/null +++ b/tests/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_git_fork_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled import ( + project_git_fork_protection_enabled, + ) + + check = project_git_fork_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_fork_protection_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + git_fork_protection=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled import ( + project_git_fork_protection_enabled, + ) + + check = project_git_fork_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has Git fork protection enabled, preventing untrusted forks from accessing secrets." + ) + assert result[0].team_id == TEAM_ID + + def test_fork_protection_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + git_fork_protection=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled import ( + project_git_fork_protection_enabled, + ) + + check = project_git_fork_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have Git fork protection enabled, allowing forks to access environment variables and trigger deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py b/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py new file mode 100644 index 0000000000..9de3eb874a --- /dev/null +++ b/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py @@ -0,0 +1,144 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_password_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import ( + project_password_protection_enabled, + ) + + check = project_password_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_password_protection_configured(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + password_protection={"deploymentType": "all"}, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import ( + project_password_protection_enabled, + ) + + check = project_password_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has password protection configured to restrict access to deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_empty_dict_password_protection(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + password_protection={}, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import ( + project_password_protection_enabled, + ) + + check = project_password_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have password protection configured for deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_no_password_protection(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + password_protection=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import ( + project_password_protection_enabled, + ) + + check = project_password_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have password protection configured for deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py b/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py new file mode 100644 index 0000000000..ff2517fbd3 --- /dev/null +++ b/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py @@ -0,0 +1,151 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + DeploymentProtectionConfig, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_production_deployment_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import ( + project_production_deployment_protection_enabled, + ) + + check = project_production_deployment_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_protection_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + production_deployment_protection=DeploymentProtectionConfig( + level="standard", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import ( + project_production_deployment_protection_enabled, + ) + + check = project_production_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has production deployment protection enabled with level 'standard'." + ) + assert result[0].team_id == TEAM_ID + + def test_protection_none_level(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + production_deployment_protection=DeploymentProtectionConfig( + level="none", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import ( + project_production_deployment_protection_enabled, + ) + + check = project_production_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have deployment protection enabled on production deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_protection_null(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + production_deployment_protection=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import ( + project_production_deployment_protection_enabled, + ) + + check = project_production_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have deployment protection enabled on production deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py b/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py new file mode 100644 index 0000000000..19d1ce8885 --- /dev/null +++ b/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_skew_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled import ( + project_skew_protection_enabled, + ) + + check = project_skew_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_skew_protection_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + skew_protection=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled import ( + project_skew_protection_enabled, + ) + + check = project_skew_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has skew protection enabled, ensuring consistent deployment versions during rollouts." + ) + assert result[0].team_id == TEAM_ID + + def test_skew_protection_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + skew_protection=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled import ( + project_skew_protection_enabled, + ) + + check = project_skew_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have skew protection enabled, which may cause version mismatches during deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py b/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py new file mode 100644 index 0000000000..72fc44f61e --- /dev/null +++ b/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py @@ -0,0 +1,113 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_custom_rules_configured: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured import ( + security_custom_rules_configured, + ) + + check = security_custom_rules_configured() + result = check.execute() + assert len(result) == 0 + + def test_custom_rules_configured(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + custom_rules=[{"id": "rule1", "name": "Block bots"}], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured import ( + security_custom_rules_configured, + ) + + check = security_custom_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has 1 custom firewall rule(s) configured." + ) + assert result[0].team_id == TEAM_ID + + def test_no_custom_rules(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + custom_rules=[], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured import ( + security_custom_rules_configured, + ) + + check = security_custom_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any custom firewall rules configured." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py b/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py new file mode 100644 index 0000000000..20e6224a24 --- /dev/null +++ b/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py @@ -0,0 +1,113 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_ip_blocking_rules_configured: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured import ( + security_ip_blocking_rules_configured, + ) + + check = security_ip_blocking_rules_configured() + result = check.execute() + assert len(result) == 0 + + def test_ip_rules_configured(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ip_blocking_rules=[{"id": "rule1", "ip": "192.168.1.0/24"}], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured import ( + security_ip_blocking_rules_configured, + ) + + check = security_ip_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has 1 IP blocking rule(s) configured." + ) + assert result[0].team_id == TEAM_ID + + def test_no_ip_rules(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ip_blocking_rules=[], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured import ( + security_ip_blocking_rules_configured, + ) + + check = security_ip_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any IP blocking rules configured." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py b/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py new file mode 100644 index 0000000000..2cc32c4e91 --- /dev/null +++ b/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py @@ -0,0 +1,155 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_managed_rulesets_enabled: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import ( + security_managed_rulesets_enabled, + ) + + check = security_managed_rulesets_enabled() + result = check.execute() + assert len(result) == 0 + + def test_managed_rulesets_enabled(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=True, + managed_rulesets={"owasp": True}, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import ( + security_managed_rulesets_enabled, + ) + + check = security_managed_rulesets_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has managed WAF rulesets enabled." + ) + assert result[0].team_id == TEAM_ID + + def test_managed_rulesets_disabled(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=False, + managed_rulesets={}, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import ( + security_managed_rulesets_enabled, + ) + + check = security_managed_rulesets_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have managed WAF rulesets enabled." + ) + assert result[0].team_id == TEAM_ID + + def test_managed_rulesets_plan_gated(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=False, + managed_rulesets=None, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import ( + security_managed_rulesets_enabled, + ) + + check = security_managed_rulesets_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for managed rulesets. Enterprise plan required to access this feature." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py b/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py new file mode 100644 index 0000000000..1c00a3c358 --- /dev/null +++ b/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py @@ -0,0 +1,113 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_rate_limiting_configured: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured import ( + security_rate_limiting_configured, + ) + + check = security_rate_limiting_configured() + result = check.execute() + assert len(result) == 0 + + def test_rate_limiting_configured(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + rate_limiting_rules=[{"id": "rule1", "max_requests": 100}], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured import ( + security_rate_limiting_configured, + ) + + check = security_rate_limiting_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has 1 rate limiting rule(s) configured." + ) + assert result[0].team_id == TEAM_ID + + def test_no_rate_limiting(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + rate_limiting_rules=[], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured import ( + security_rate_limiting_configured, + ) + + check = security_rate_limiting_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any rate limiting rules configured." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py b/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py new file mode 100644 index 0000000000..1641868175 --- /dev/null +++ b/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py @@ -0,0 +1,115 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_waf_enabled: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import ( + security_waf_enabled, + ) + + check = security_waf_enabled() + result = check.execute() + assert len(result) == 0 + + def test_waf_enabled(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=True, + managed_rulesets={}, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import ( + security_waf_enabled, + ) + + check = security_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has the Web Application Firewall enabled." + ) + assert result[0].team_id == TEAM_ID + + def test_waf_disabled(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=False, + managed_rulesets={}, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import ( + security_waf_enabled, + ) + + check = security_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have the Web Application Firewall enabled." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py b/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py new file mode 100644 index 0000000000..7f08db7fa1 --- /dev/null +++ b/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import VercelTeam +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_directory_sync_enabled: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.teams = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled import ( + team_directory_sync_enabled, + ) + + check = team_directory_sync_enabled() + result = check.execute() + assert len(result) == 0 + + def test_directory_sync_enabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + directory_sync_enabled=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled import ( + team_directory_sync_enabled, + ) + + check = team_directory_sync_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has directory sync (SCIM) enabled for automated user provisioning." + ) + assert result[0].team_id == "" + + def test_directory_sync_disabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + directory_sync_enabled=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled import ( + team_directory_sync_enabled, + ) + + check = team_directory_sync_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} does not have directory sync (SCIM) enabled. User provisioning and deprovisioning must be managed manually." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege_test.py b/tests/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege_test.py new file mode 100644 index 0000000000..f125715dbd --- /dev/null +++ b/tests/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege_test.py @@ -0,0 +1,266 @@ +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import ( + SAMLConfig, + VercelTeam, + VercelTeamMember, +) +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_member_role_least_privilege: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.teams = {} + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 0 + + def test_member_least_privilege(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id="member_1", + email="member@example.com", + role="MEMBER", + status="active", + ), + ], + ) + } + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has 0 owner(s) out of 1 active members. Small team with minimum required owner — least privilege threshold not applicable." + ) + assert result[0].team_id == "" + + def test_small_team_single_owner(self): + """Small team (<5 members) with 1 owner gets a PASS (small team exception).""" + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id="member_1", + email="member@example.com", + role="OWNER", + status="active", + ), + ], + ) + } + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has 1 owner(s) out of 1 active members. Small team with minimum required owner — least privilege threshold not applicable." + ) + assert result[0].team_id == "" + + def test_large_team_too_many_owners(self): + """Large team (>=5 members) with >20% owners gets a FAIL.""" + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id=f"member_{i}", + email=f"member{i}@example.com", + role="OWNER" if i <= 2 else "MEMBER", + status="active", + ) + for i in range(1, 6) + ], + ) + } + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert "40% exceeds the 20% threshold" in result[0].status_extended + assert result[0].team_id == "" + + def test_large_team_exceeds_max_owners(self): + """Large team within percentage but exceeding max_owners gets a FAIL.""" + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id=f"member_{i}", + email=f"member{i}@example.com", + role="OWNER" if i <= 4 else "MEMBER", + status="active", + ) + for i in range(1, 21) + ], + ) + } + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "4 owners exceeds the maximum of 3" in result[0].status_extended + + def test_custom_thresholds(self): + """Custom thresholds via audit_config are respected.""" + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id=f"member_{i}", + email=f"member{i}@example.com", + role="OWNER" if i <= 2 else "MEMBER", + status="active", + ) + for i in range(1, 6) + ], + ) + } + team_client.audit_config = { + "max_owner_percentage": 50, + "max_owners": 5, + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "within the configured thresholds (50% / max 5 owners)" + in result[0].status_extended + ) diff --git a/tests/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations_test.py b/tests/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations_test.py new file mode 100644 index 0000000000..5ca777a53f --- /dev/null +++ b/tests/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations_test.py @@ -0,0 +1,130 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import ( + VercelTeam, + VercelTeamMember, +) +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_no_stale_invitations: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.audit_config = {"stale_invitation_threshold_days": 30} + team_client.teams = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations import ( + team_no_stale_invitations, + ) + + check = team_no_stale_invitations() + result = check.execute() + assert len(result) == 0 + + def test_no_stale_invitations(self): + team_client = mock.MagicMock + team_client.audit_config = {"stale_invitation_threshold_days": 30} + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + members=[ + VercelTeamMember( + id="member_1", + email="invited@example.com", + role="MEMBER", + status="invited", + created_at=datetime.now(timezone.utc) - timedelta(days=5), + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations import ( + team_no_stale_invitations, + ) + + check = team_no_stale_invitations() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has no stale pending invitations older than 30 days." + ) + assert result[0].team_id == "" + + def test_stale_invitation(self): + team_client = mock.MagicMock + team_client.audit_config = {"stale_invitation_threshold_days": 30} + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + members=[ + VercelTeamMember( + id="member_1", + email="old_invite@example.com", + role="MEMBER", + status="invited", + created_at=datetime.now(timezone.utc) - timedelta(days=60), + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations import ( + team_no_stale_invitations, + ) + + check = team_no_stale_invitations() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has 1 stale pending invitation(s) older than 30 days." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py b/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py new file mode 100644 index 0000000000..b7f40dd653 --- /dev/null +++ b/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py @@ -0,0 +1,108 @@ +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import SAMLConfig, VercelTeam +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_saml_sso_enabled: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.teams = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled import ( + team_saml_sso_enabled, + ) + + check = team_saml_sso_enabled() + result = check.execute() + assert len(result) == 0 + + def test_saml_enabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="enabled", enforced=False), + members=[], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled import ( + team_saml_sso_enabled, + ) + + check = team_saml_sso_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended == f"Team {TEAM_NAME} has SAML SSO enabled." + ) + assert result[0].team_id == "" + + def test_saml_disabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled import ( + team_saml_sso_enabled, + ) + + check = team_saml_sso_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} does not have SAML SSO enabled." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py b/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py new file mode 100644 index 0000000000..360d6cdbd8 --- /dev/null +++ b/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py @@ -0,0 +1,144 @@ +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import SAMLConfig, VercelTeam +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_saml_sso_enforced: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.teams = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import ( + team_saml_sso_enforced, + ) + + check = team_saml_sso_enforced() + result = check.execute() + assert len(result) == 0 + + def test_saml_enforced(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="enabled", enforced=True), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import ( + team_saml_sso_enforced, + ) + + check = team_saml_sso_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} enforces SAML SSO for all members." + ) + assert result[0].team_id == "" + + def test_saml_enabled_not_enforced(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="enabled", enforced=False), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import ( + team_saml_sso_enforced, + ) + + check = team_saml_sso_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has SAML SSO enabled but does not enforce it. Members can still authenticate without SSO." + ) + assert result[0].team_id == "" + + def test_saml_disabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import ( + team_saml_sso_enforced, + ) + + check = team_saml_sso_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} does not have SAML SSO enforced." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/vercel_fixtures.py b/tests/providers/vercel/vercel_fixtures.py new file mode 100644 index 0000000000..70775bb377 --- /dev/null +++ b/tests/providers/vercel/vercel_fixtures.py @@ -0,0 +1,59 @@ +from unittest.mock import MagicMock + +from prowler.providers.vercel.models import ( + VercelIdentityInfo, + VercelSession, + VercelTeamInfo, +) + +# Vercel Identity +TEAM_ID = "team_test123" +TEAM_NAME = "Test Team" +TEAM_SLUG = "test-team" +USER_ID = "user_test456" +USERNAME = "testuser" +USER_EMAIL = "test@example.com" + +# Vercel Credentials +API_TOKEN = "test-vercel-api-token" + +# Project Constants +PROJECT_ID = "prj_test789" +PROJECT_NAME = "my-test-project" + +# Domain Constants +DOMAIN_NAME = "example.com" + +# Deployment Constants +DEPLOYMENT_ID = "dpl_test012" + + +def set_mocked_vercel_provider( + api_token: str = API_TOKEN, + team_id: str = TEAM_ID, + identity: VercelIdentityInfo = None, + audit_config: dict = None, +): + """Create a mocked VercelProvider for testing.""" + provider = MagicMock() + provider.type = "vercel" + provider.session = VercelSession( + token=api_token, + team_id=team_id, + http_session=MagicMock(), + ) + provider.identity = identity or VercelIdentityInfo( + user_id=USER_ID, + username=USERNAME, + email=USER_EMAIL, + team=VercelTeamInfo( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + ), + ) + provider.audit_config = audit_config or {"max_retries": 3} + provider.fixer_config = {} + provider.filter_projects = None + + return provider diff --git a/tests/providers/vercel/vercel_provider_test.py b/tests/providers/vercel/vercel_provider_test.py new file mode 100644 index 0000000000..56117ae6c4 --- /dev/null +++ b/tests/providers/vercel/vercel_provider_test.py @@ -0,0 +1,224 @@ +import os +from unittest import mock +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.common.models import Connection +from prowler.providers.vercel.exceptions.exceptions import ( + VercelAuthenticationError, + VercelCredentialsError, +) +from prowler.providers.vercel.models import VercelIdentityInfo, VercelSession +from prowler.providers.vercel.vercel_provider import VercelProvider +from tests.providers.vercel.vercel_fixtures import ( + API_TOKEN, + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + USER_EMAIL, + USER_ID, + USERNAME, +) + + +class TestVercelProviderSetupSession: + def test_setup_session_with_env_var(self): + with mock.patch.dict(os.environ, {"VERCEL_TOKEN": API_TOKEN}, clear=False): + session = VercelProvider.setup_session() + + assert isinstance(session, VercelSession) + assert session.token == API_TOKEN + assert session.http_session is not None + + def test_setup_session_with_api_token_param(self): + with mock.patch.dict(os.environ, {}, clear=True): + session = VercelProvider.setup_session(api_token=API_TOKEN) + + assert isinstance(session, VercelSession) + assert session.token == API_TOKEN + assert session.http_session is not None + + def test_setup_session_with_team_id_param(self): + with mock.patch.dict(os.environ, {}, clear=True): + session = VercelProvider.setup_session(api_token=API_TOKEN, team_id=TEAM_ID) + + assert session.token == API_TOKEN + assert session.team_id == TEAM_ID + + def test_setup_session_no_credentials_raises(self): + with mock.patch.dict(os.environ, {}, clear=True): + os.environ.pop("VERCEL_TOKEN", None) + with pytest.raises(VercelCredentialsError): + VercelProvider.setup_session() + + def test_setup_session_team_from_env(self): + with mock.patch.dict( + os.environ, {"VERCEL_TOKEN": API_TOKEN, "VERCEL_TEAM": TEAM_ID} + ): + session = VercelProvider.setup_session() + + assert session.team_id == TEAM_ID + + +class TestVercelProviderSetupIdentity: + def test_setup_identity_with_team(self): + mock_session = VercelSession( + token=API_TOKEN, team_id=TEAM_ID, http_session=MagicMock() + ) + + # Mock user response + user_response = MagicMock() + user_response.status_code = 200 + user_response.json.return_value = { + "user": { + "id": USER_ID, + "username": USERNAME, + "email": USER_EMAIL, + } + } + user_response.raise_for_status = MagicMock() + + # Mock team response + team_response = MagicMock() + team_response.status_code = 200 + team_response.json.return_value = { + "id": TEAM_ID, + "name": TEAM_NAME, + "slug": TEAM_SLUG, + } + team_response.raise_for_status = MagicMock() + + def mock_get(url, **kwargs): + if "/v2/user" in url: + return user_response + if f"/v2/teams/{TEAM_ID}" in url: + return team_response + return MagicMock() + + mock_session.http_session.get = mock_get + + identity = VercelProvider.setup_identity(mock_session) + + assert isinstance(identity, VercelIdentityInfo) + assert identity.user_id == USER_ID + assert identity.username == USERNAME + assert identity.email == USER_EMAIL + assert identity.team is not None + assert identity.team.id == TEAM_ID + assert identity.team.name == TEAM_NAME + assert identity.team.slug == TEAM_SLUG + + def test_setup_identity_personal_account(self): + mock_session = VercelSession( + token=API_TOKEN, team_id=None, http_session=MagicMock() + ) + + user_response = MagicMock() + user_response.status_code = 200 + user_response.json.return_value = { + "user": { + "id": USER_ID, + "username": USERNAME, + "email": USER_EMAIL, + } + } + user_response.raise_for_status = MagicMock() + + mock_session.http_session.get = MagicMock(return_value=user_response) + + identity = VercelProvider.setup_identity(mock_session) + + assert identity.team is None + assert identity.user_id == USER_ID + + +class TestVercelProviderValidateCredentials: + def test_valid_credentials(self): + mock_session = VercelSession( + token=API_TOKEN, team_id=None, http_session=MagicMock() + ) + + response = MagicMock() + response.status_code = 200 + response.raise_for_status = MagicMock() + mock_session.http_session.get = MagicMock(return_value=response) + + # Should not raise + VercelProvider.validate_credentials(mock_session) + + def test_invalid_token_raises(self): + mock_session = VercelSession( + token="invalid", team_id=None, http_session=MagicMock() + ) + + response = MagicMock() + response.status_code = 401 + mock_session.http_session.get = MagicMock(return_value=response) + + with pytest.raises(VercelAuthenticationError): + VercelProvider.validate_credentials(mock_session) + + def test_forbidden_raises(self): + mock_session = VercelSession( + token=API_TOKEN, team_id=None, http_session=MagicMock() + ) + + response = MagicMock() + response.status_code = 403 + mock_session.http_session.get = MagicMock(return_value=response) + + with pytest.raises(VercelAuthenticationError): + VercelProvider.validate_credentials(mock_session) + + +class TestVercelProviderTestConnection: + @patch.object(VercelProvider, "validate_credentials") + @patch.object(VercelProvider, "setup_session") + def test_successful_connection(self, mock_setup_session, mock_validate): + mock_setup_session.return_value = VercelSession( + token=API_TOKEN, team_id=None, http_session=MagicMock() + ) + mock_validate.return_value = None + + result = VercelProvider.test_connection(raise_on_exception=False) + + assert isinstance(result, Connection) + assert result.is_connected is True + + @patch.object(VercelProvider, "validate_credentials") + @patch.object(VercelProvider, "setup_session") + def test_successful_connection_with_params(self, mock_setup_session, mock_validate): + mock_setup_session.return_value = VercelSession( + token=API_TOKEN, team_id=TEAM_ID, http_session=MagicMock() + ) + mock_validate.return_value = None + + result = VercelProvider.test_connection( + api_token=API_TOKEN, team_id=TEAM_ID, raise_on_exception=False + ) + + assert isinstance(result, Connection) + assert result.is_connected is True + mock_setup_session.assert_called_once_with(api_token=API_TOKEN, team_id=TEAM_ID) + + @patch.object(VercelProvider, "setup_session") + def test_failed_connection_no_credentials(self, mock_setup_session): + mock_setup_session.side_effect = VercelCredentialsError( + message="No credentials" + ) + + result = VercelProvider.test_connection(raise_on_exception=False) + + assert isinstance(result, Connection) + assert result.is_connected is False + assert result.error is not None + + @patch.object(VercelProvider, "setup_session") + def test_failed_connection_raises(self, mock_setup_session): + mock_setup_session.side_effect = VercelCredentialsError( + message="No credentials" + ) + + with pytest.raises(VercelCredentialsError): + VercelProvider.test_connection(raise_on_exception=True)