Compare commits

...

20 Commits

Author SHA1 Message Date
Andoni A.
ffd114f10c docs(sdk): remove GitHub Code Scanning workflow examples from SARIF docs 2026-04-10 10:55:42 +02:00
Andoni A.
fad845669b docs(sdk): add SARIF output format documentation
- Add SARIF section to reporting docs with format details and GitHub Code Scanning example
- Update IaC getting-started with SARIF output and upload instructions
- Update basic usage to mention SARIF as IaC-only format
- Add IaC SARIF workflow example to CI/CD pipeline cookbook
- Mention SARIF in developer guide output formats list
2026-04-10 10:52:37 +02:00
Andoni A.
b28d6a4fcc feat(sdk): enrich SARIF output with markdown help, descriptive rule names, and secret line numbers
- Use CheckTitle for rule.name instead of duplicating rule.id
- Add help.markdown with severity table, remediation text, and link
- Fix secret findings missing line numbers by reading top-level StartLine/EndLine from Trivy output
2026-04-10 08:38:07 +02:00
Andoni A.
cc658fc958 fix(sdk): improve SARIF location builder type safety and line validation
- Return Optional[dict] instead of empty dict from _build_location
- Guard against zero line numbers (SARIF spec requires >= 1)
- Add test for zero line number edge case
2026-04-09 16:14:24 +02:00
Andoni A.
86b2297a5b feat(sdk): restrict SARIF output format to IaC provider only 2026-04-09 11:49:58 +02:00
Andoni A.
58e5e5bb2a fix(sdk): add comment to empty except block in SARIF location builder 2026-04-09 11:27:59 +02:00
Andoni A.
8bde7b6eb9 docs(sdk): add PR reference to SARIF changelog entry 2026-04-09 10:35:08 +02:00
Andoni A.
a8991f1232 refactor(sdk): add docstrings and align fd close guard in SARIF output 2026-04-09 10:34:32 +02:00
Andoni A.
4f0894dd92 fix(sdk): improve SARIF output after review
- Exclude muted findings from SARIF output
- Fix _data type contract (store as list for base class consistency)
- Add missing tests: helpUri conditional, malformed line ranges, muted exclusion
- Remove test_empty_findings (tested base class, not SARIF)
- Fix O(n²) ruleIndex lookup with parallel dict
- Remove tests/__init__.py per SDK test guidelines
- Display SARIF file path in CLI summary table
- Add CHANGELOG entry
- Use native list type hint instead of typing.List
- Clean up temp file in test
2026-04-09 10:22:14 +02:00
Andoni A.
5bf816ee42 fix(sdk): handle empty message and helpUri in SARIF output
Ensure result message.text is never empty (falls back to CheckTitle)
and omit helpUri when RelatedUrl is empty to avoid SARIF validation
warnings.
2026-04-02 16:06:11 +02:00
Andoni A.
42ab40d079 fix(sdk): drop partialFingerprints from SARIF output
Let github/codeql-action/upload-sarif compute fingerprints from file
content instead of using Prowler UIDs, which conflict with GitHub's
expected format.
2026-04-02 15:48:43 +02:00
Andoni A.
2ce706e474 feat(sdk): add SARIF 2.1.0 output format for GitHub Code Scanning
Add SARIF output class that converts Prowler findings into SARIF 2.1.0
format compatible with github/codeql-action/upload-sarif. Includes
severity mapping, rule deduplication, and file location support with
line ranges for IaC findings.
2026-04-02 14:51:38 +02:00
Alejandro Bailo
4f86667433 feat(sdk): add Vercel provider with 30 security checks (#10189)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: Andoni A. <14891798+andoniaf@users.noreply.github.com>
2026-03-31 16:21:22 +02:00
Andoni Alonso
4bb1e5cff7 fix(sdk): redact sensitive CLI flags in HTML output (#10518) 2026-03-31 15:01:09 +02:00
Pedro Martín
99b80ebbd9 chore(actions): add pr-check-compliance-mapping action (#10526) 2026-03-31 13:38:20 +02:00
rchotacode
d18c5a8974 fix(oci): fix identity clients (#10520)
Co-authored-by: Ronan Chota <ronan.chota@saic.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-31 09:42:19 +02:00
Hugo Pereira Brito
ab00c2dce1 feat(m365): add entra_conditional_access_policy_block_elevated_insider_risk security check (#10234)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-03-30 17:27:00 +02:00
Pablo Fernandez Guerra (PFE)
765f9c72f2 docs: add missing pre-commit hooks setup for TruffleHog, Safety and Hadolint (#10448)
Co-authored-by: Pablo Fernandez <pfe@NB0240.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-03-30 16:43:32 +02:00
Erich Blume
de5bb94ff6 fix(image): pass registry arguments through init_global_provider (#10470)
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
2026-03-30 15:19:01 +02:00
lydiavilchez
c009a2128a feat(google-workspace): add CISA SCuBA Baselines compliance (#10466)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-03-30 14:33:38 +02:00
196 changed files with 12602 additions and 36 deletions

7
.github/labeler.yml vendored
View File

@@ -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:

View File

@@ -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
# ============================================

View File

@@ -0,0 +1,180 @@
name: 'Tools: Check Compliance Mapping'
on:
pull_request:
types:
- 'opened'
- 'synchronize'
- 'reopened'
- 'labeled'
- 'unlabeled'
branches:
- 'master'
- 'v5.*'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check-compliance-mapping:
if: contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
with:
files: |
prowler/providers/**/services/**/*.metadata.json
prowler/compliance/**/*.json
- name: Check if new checks are mapped in compliance
id: compliance-check
run: |
ADDED_METADATA="${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}"
ALL_CHANGED="${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}"
# Filter only new metadata files (new checks)
new_checks=""
for f in $ADDED_METADATA; do
case "$f" in *.metadata.json) new_checks="$new_checks $f" ;; esac
done
if [ -z "$(echo "$new_checks" | tr -d ' ')" ]; then
echo "No new checks detected."
echo "has_new_checks=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Collect compliance files changed in this PR
changed_compliance=""
for f in $ALL_CHANGED; do
case "$f" in prowler/compliance/*.json) changed_compliance="$changed_compliance $f" ;; esac
done
UNMAPPED=""
MAPPED=""
for metadata_file in $new_checks; do
check_dir=$(dirname "$metadata_file")
check_id=$(basename "$check_dir")
provider=$(echo "$metadata_file" | cut -d'/' -f3)
# Read CheckID from the metadata JSON for accuracy
if [ -f "$metadata_file" ]; then
json_check_id=$(python3 -c "import json; print(json.load(open('$metadata_file')).get('CheckID', ''))" 2>/dev/null || echo "")
if [ -n "$json_check_id" ]; then
check_id="$json_check_id"
fi
fi
# Search for the check ID in compliance files changed in this PR
found_in=""
for comp_file in $changed_compliance; do
if grep -q "\"${check_id}\"" "$comp_file" 2>/dev/null; then
found_in="${found_in}$(basename "$comp_file" .json), "
fi
done
if [ -n "$found_in" ]; then
found_in=$(echo "$found_in" | sed 's/, $//')
MAPPED="${MAPPED}- \`${check_id}\` (\`${provider}\`): ${found_in}"$'\n'
else
UNMAPPED="${UNMAPPED}- \`${check_id}\` (\`${provider}\`)"$'\n'
fi
done
echo "has_new_checks=true" >> "$GITHUB_OUTPUT"
if [ -n "$UNMAPPED" ]; then
echo "has_unmapped=true" >> "$GITHUB_OUTPUT"
else
echo "has_unmapped=false" >> "$GITHUB_OUTPUT"
fi
{
echo "unmapped<<EOF"
echo -e "${UNMAPPED}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
{
echo "mapped<<EOF"
echo -e "${MAPPED}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
env:
STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES: ${{ steps.changed-files.outputs.added_files }}
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
- name: Manage compliance review label
if: steps.compliance-check.outputs.has_new_checks == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HAS_UNMAPPED: ${{ steps.compliance-check.outputs.has_unmapped }}
run: |
LABEL_NAME="needs-compliance-review"
if [ "$HAS_UNMAPPED" = "true" ]; then
echo "Adding compliance review label to PR #${PR_NUMBER}..."
gh pr edit "$PR_NUMBER" --add-label "$LABEL_NAME" --repo "${{ github.repository }}" || true
else
echo "Removing compliance review label from PR #${PR_NUMBER}..."
gh pr edit "$PR_NUMBER" --remove-label "$LABEL_NAME" --repo "${{ github.repository }}" || true
fi
- name: Find existing compliance comment
if: steps.compliance-check.outputs.has_new_checks == 'true' && github.event.pull_request.head.repo.full_name == github.repository
id: find-comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: '<!-- compliance-mapping-check -->'
- name: Create or update compliance comment
if: steps.compliance-check.outputs.has_new_checks == 'true' && github.event.pull_request.head.repo.full_name == github.repository
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
body: |
<!-- compliance-mapping-check -->
## Compliance Mapping Review
This PR adds new checks. Please verify that they have been mapped to the relevant compliance framework requirements.
${{ steps.compliance-check.outputs.unmapped != '' && format('### New checks not mapped to any compliance framework in this PR
{0}
> Please review whether these checks should be added to compliance framework requirements in `prowler/compliance/<provider>/`. Each compliance JSON has a `Checks` array inside each requirement — add the check ID there if it satisfies that requirement.', steps.compliance-check.outputs.unmapped) || '' }}
${{ steps.compliance-check.outputs.mapped != '' && format('### New checks already mapped in this PR
{0}', steps.compliance-check.outputs.mapped) || '' }}
Use the `no-compliance-check` label to skip this check.

View File

@@ -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'

View File

@@ -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]
@@ -239,6 +240,21 @@ pnpm start
> Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started.
**Pre-commit Hooks Setup**
Some pre-commit hooks require tools installed on your system:
1. **Install [TruffleHog](https://github.com/trufflesecurity/trufflehog#install)** (secret scanning) — see the [official installation options](https://github.com/trufflesecurity/trufflehog#install).
2. **Install [Safety](https://github.com/pyupio/safety)** (dependency vulnerability checking):
```console
# Requires a Python environment (e.g. via pyenv)
pip install safety
```
3. **Install [Hadolint](https://github.com/hadolint/hadolint#install)** (Dockerfile linting) — see the [official installation options](https://github.com/hadolint/hadolint#install).
## Prowler CLI
### Pip package
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >=3.10, <3.13:

View File

@@ -8,7 +8,7 @@ Prowler supports multiple output formats, allowing users to tailor findings pres
- Output Organization in Prowler
Prowler outputs are managed within the `/lib/outputs` directory. Each format—such as JSON, CSV, HTML—is implemented as a Python class.
Prowler outputs are managed within the `/lib/outputs` directory. Each format—such as JSON, CSV, HTML, SARIF—is implemented as a Python class.
- Outputs are generated based on scan findings, which are stored as structured dictionaries containing details such as:

View File

@@ -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"
]
}
]
},

View File

@@ -25,7 +25,12 @@ If you prefer the former verbose output, use: `--verbose`. This allows seeing mo
## Report Generation
By default, Prowler generates CSV, JSON-OCSF, and HTML reports. To generate a JSON-ASFF report (used by AWS Security Hub), specify `-M` or `--output-modes`:
By default, Prowler generates CSV, JSON-OCSF, and HTML reports. Additional provider-specific formats are available:
* **JSON-ASFF** (AWS only): Used by AWS Security Hub
* **SARIF** (IaC only): Used by GitHub Code Scanning
To specify output formats, use the `-M` or `--output-modes` flag:
```console
prowler <provider> -M csv json-asff json-ocsf html

View File

@@ -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 |

View File

@@ -141,6 +141,22 @@ The following list includes all the GitHub checks with configurable variables th
|--------------------------------------------|---------------------------------------------|---------|
| `repository_inactive_not_archived` | `inactive_not_archived_days_threshold` | Integer |
## Vercel
### Configurable Checks
The following list includes all the Vercel checks with configurable variables that can be changed in the configuration YAML file:
| Check Name | Value | Type |
|-----------------------------------------------------|------------------------------------|-----------------|
| `authentication_no_stale_tokens` | `stale_token_threshold_days` | Integer |
| `authentication_token_not_expired` | `days_to_expire_threshold` | Integer |
| `deployment_production_uses_stable_target` | `stable_branches` | List of Strings |
| `domain_ssl_certificate_valid` | `days_to_expire_threshold` | Integer |
| `project_environment_no_secrets_in_plain_type` | `secret_suffixes` | List of Strings |
| `team_member_role_least_privilege` | `max_owner_percentage` | Integer |
| `team_member_role_least_privilege` | `max_owners` | Integer |
| `team_no_stale_invitations` | `stale_invitation_threshold_days` | Integer |
## Config YAML File Structure
<Note>
@@ -624,5 +640,29 @@ github:
# github.repository_inactive_not_archived
inactive_not_archived_days_threshold: 180
# Vercel Configuration
vercel:
# vercel.deployment_production_uses_stable_target
stable_branches:
- "main"
- "master"
# vercel.authentication_token_not_expired & vercel.domain_ssl_certificate_valid
days_to_expire_threshold: 7
# vercel.authentication_no_stale_tokens
stale_token_threshold_days: 90
# vercel.team_no_stale_invitations
stale_invitation_threshold_days: 30
# vercel.team_member_role_least_privilege
max_owner_percentage: 20
max_owners: 3
# vercel.project_environment_no_secrets_in_plain_type
secret_suffixes:
- "_KEY"
- "_SECRET"
- "_TOKEN"
- "_PASSWORD"
- "_API_KEY"
- "_PRIVATE_KEY"
```

View File

@@ -61,6 +61,7 @@ Prowler natively supports the following reporting output formats:
- JSON-OCSF
- JSON-ASFF (AWS only)
- HTML
- SARIF (IaC only)
Hereunder is the structure for each of the supported report formats by Prowler:
@@ -368,6 +369,29 @@ Each finding is a `json` object within a list.
The following image is an example of the HTML output:
<img src="/images/cli/reporting/html-output.png" />
### SARIF (IaC Only)
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="5.23.0" />
The SARIF (Static Analysis Results Interchange Format) output generates a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) document compatible with GitHub Code Scanning and other SARIF-compatible tools. This format is exclusively available for the IaC provider, as it is designed for static analysis results that reference specific files and line numbers.
```console
prowler iac --scan-repository-url https://github.com/user/repo -M sarif
```
<Note>
The SARIF output format is only available when using the `iac` provider. Attempting to use it with other providers results in an error.
</Note>
The SARIF output includes:
* **Rules:** Each unique check ID produces a rule entry with severity, description, remediation, and a markdown help panel.
* **Results:** Only failed (non-muted) findings are included, with file paths and line numbers for precise annotation.
* **Severity mapping:** Prowler severities map to SARIF levels (`critical`/`high` → `error`, `medium` → `warning`, `low`/`informational` → `note`).
## V4 Deprecations
Some deprecations have been made to unify formats and improve outputs.

View File

@@ -29,7 +29,7 @@ Prowler IaC provider scans the following Infrastructure as Code configurations f
- For remote repository scans, authentication can be provided via [git URL](https://git-scm.com/docs/git-clone#_git_urls), CLI flags or environment variables.
- Check the [IaC Authentication](/user-guide/providers/iac/authentication) page for more details.
- Mutelist logic ([filtering](https://trivy.dev/latest/docs/configuration/filtering/)) is handled by Trivy, not Prowler.
- Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.).
- Results are output in the same formats as other Prowler providers (CSV, JSON-OCSF, HTML), plus [SARIF](/user-guide/cli/tutorials/reporting#sarif-iac-only) for GitHub Code Scanning integration.
## Prowler App
@@ -140,8 +140,20 @@ prowler iac --scan-path ./my-iac-directory --exclude-path ./my-iac-directory/tes
### Output
Use the standard Prowler output options, for example:
Use the standard Prowler output options. The IaC provider also supports [SARIF](/user-guide/cli/tutorials/reporting#sarif-iac-only) output for GitHub Code Scanning integration:
```sh
prowler iac --scan-path ./iac --output-formats csv json html
prowler iac --scan-path ./iac --output-formats csv json-ocsf html
```
#### SARIF Output
<VersionBadge version="5.23.0" />
To generate SARIF output for integration with SARIF-compatible tools:
```sh
prowler iac --scan-repository-url https://github.com/user/repo -M sarif
```
See the [SARIF reporting documentation](/user-guide/cli/tutorials/reporting#sarif-iac-only) for details on the format and severity mapping.

View File

@@ -0,0 +1,137 @@
---
title: "Vercel Authentication in Prowler"
---
import { VersionBadge } from "/snippets/version-badge.mdx"
<VersionBadge version="5.21.0" />
Prowler for Vercel authenticates using an **API Token**.
## Required Permissions
Prowler requires read-only access to Vercel teams, projects, deployments, domains, and security settings. The API Token must have access to the target team scope.
<Note>
Vercel API Tokens inherit the permissions of the user that created them. Ensure the user has at least a **Viewer** role on the team to be scanned.
</Note>
| Resource | Access | Description |
|----------|--------|-------------|
| Teams | Read | Required to list teams, members, and SSO configuration |
| Projects | Read | Required to list projects, environment variables, and deployment protection settings |
| Deployments | Read | Required to list deployments and protection status |
| Domains | Read | Required to list domains, DNS records, and SSL certificates |
| Firewall | Read | Required to read WAF rules, rate limiting, and IP blocking configuration |
---
## API Token
### Step 1: Create an API Token
1. Log into the [Vercel Dashboard](https://vercel.com/dashboard).
2. Click the account avatar in the bottom-left corner and select "Settings".
![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.
<Warning>
Vercel only displays the token once. Copy it immediately and store it securely. If lost, a new token must be created.
</Warning>
### Step 2: Provide the Token to Prowler
Export the token as an environment variable:
```console
export VERCEL_TOKEN="your-api-token-here"
prowler vercel
```
---
## Team Scoping (Optional)
By default, Prowler auto-discovers all teams the authenticated user belongs to and scans each one. To restrict the scan to a specific team, provide the Team ID.
### Locate the Team ID
1. In the Vercel Dashboard, navigate to "Settings" for the target team.
2. Scroll down to the **Team ID** section and copy the value.
![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.

View File

@@ -0,0 +1,108 @@
---
title: "Getting Started With Vercel on Prowler"
---
import { VersionBadge } from "/snippets/version-badge.mdx"
Prowler for Vercel scans teams and projects for security misconfigurations, including deployment protection, environment variable exposure, WAF rules, domain configuration, team access controls, and more.
## Prerequisites
Set up authentication for Vercel with the [Vercel Authentication](/user-guide/providers/vercel/authentication) guide before starting:
- Create a Vercel API Token with access to the target team
- Identify the Team ID (optional, required to scope the scan to a single team)
## Prowler CLI
<VersionBadge version="5.22.0" />
### Step 1: Set Up Authentication
Follow the [Vercel Authentication](/user-guide/providers/vercel/authentication) guide to create an API Token, then export it:
```console
export VERCEL_TOKEN="your-api-token-here"
```
Optionally, scope the scan to a specific team:
```console
export VERCEL_TEAM="team_yourteamid"
```
### Step 2: Run the First Scan
Run a baseline scan after credentials are configured:
```console
prowler vercel
```
Prowler automatically discovers all teams accessible with the provided token and runs security checks against them.
### Step 3: Filter the Scan Scope (Optional)
#### Filter by Team
To scan a specific team, set the `VERCEL_TEAM` environment variable with the Team ID or slug:
```console
export VERCEL_TEAM="team_yourteamid"
prowler vercel
```
<Note>
When no team is specified, Prowler auto-discovers all teams the authenticated user belongs to and scans each one.
</Note>
#### Filter by Project
To scan only specific projects, use the `--project` argument:
```console
prowler vercel --project my-project-name
```
Multiple projects can be specified:
```console
prowler vercel --project my-project-name another-project
```
Project IDs are also supported:
```console
prowler vercel --project prj_abc123def456
```
### Step 4: Use a Custom Configuration (Optional)
Prowler uses a configuration file to customize provider behavior. The Vercel configuration includes:
```yaml
vercel:
# Maximum number of retries for API requests (default is 3)
max_retries: 3
```
To use a custom configuration:
```console
prowler vercel --config-file /path/to/config.yaml
```
---
## Supported Services
Prowler for Vercel includes security checks across the following services:
| Service | Description |
|---------|-------------|
| **Authentication** | Token expiration and staleness checks |
| **Deployment** | Preview deployment access and production stability |
| **Domain** | DNS configuration, SSL certificates, and wildcard exposure |
| **Project** | Deployment protection, environment variable security, fork protection, and skew protection |
| **Security** | Web Application Firewall (WAF), rate limiting, IP blocking, and managed rulesets |
| **Team** | SSO enforcement, directory sync, member access, and invitation hygiene |

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

View File

@@ -14,8 +14,12 @@ 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)
- 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)
- SARIF output format for the IaC provider, enabling GitHub Code Scanning integration via `--output-formats sarif` [(#10626)](https://github.com/prowler-cloud/prowler/pull/10626)
### 🔄 Changed
@@ -26,6 +30,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `return` statements in `finally` blocks replaced across IAM, Organizations, GCP provider, and custom checks metadata to stop silently swallowing exceptions [(#10102)](https://github.com/prowler-cloud/prowler/pull/10102)
### 🔐 Security
- Sensitive CLI flag values (tokens, keys, passwords) in HTML output "Parameters used" field now redacted to prevent credential leaks [(#10518)](https://github.com/prowler-cloud/prowler/pull/10518)
---
## [5.22.1] (Prowler UNRELEASED)
@@ -38,6 +46,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Oracle Cloud `kms_key_rotation_enabled` now checks current key version age to avoid false positives on vaults without auto-rotation support [(#10450)](https://github.com/prowler-cloud/prowler/pull/10450)
- Oracle Cloud patch for filestorage, blockstorage, kms, and compute services in OCI to allow for region scanning outside home [(#10455)](https://github.com/prowler-cloud/prowler/pull/10472)
- Oracle cloud provider now supports multi-region filtering [(#10435)](https://github.com/prowler-cloud/prowler/pull/10473)
- `prowler image --registry` failing with `ImageNoImagesProvidedError` due to registry arguments not being forwarded to `ImageProvider` in `init_global_provider` [(#10457)](https://github.com/prowler-cloud/prowler/issues/10457)
- Oracle Cloud multi-region support for identity client configuration in blockstorage, identity, and filestorage services [(#10519)](https://github.com/prowler-cloud/prowler/pull/10520)
---

View File

@@ -18,6 +18,7 @@ from prowler.config.config import (
json_asff_file_suffix,
json_ocsf_file_suffix,
orange_color,
sarif_file_suffix,
)
from prowler.lib.banner import print_banner
from prowler.lib.check.check import (
@@ -71,6 +72,9 @@ from prowler.lib.outputs.compliance.cis.cis_googleworkspace import GoogleWorkspa
from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS
from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS
from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS
from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import (
GoogleWorkspaceCISASCuBA,
)
from prowler.lib.outputs.compliance.compliance import display_compliance_table
from prowler.lib.outputs.compliance.csa.csa_alibabacloud import AlibabaCloudCSA
from prowler.lib.outputs.compliance.csa.csa_aws import AWSCSA
@@ -119,6 +123,7 @@ from prowler.lib.outputs.html.html import HTML
from prowler.lib.outputs.ocsf.ingestion import send_ocsf_to_api
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.lib.outputs.outputs import extract_findings_statistics, report
from prowler.lib.outputs.sarif.sarif import SARIF
from prowler.lib.outputs.slack.slack import Slack
from prowler.lib.outputs.summary_table import display_summary_table
from prowler.providers.alibabacloud.models import AlibabaCloudOutputOptions
@@ -142,6 +147,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():
@@ -395,6 +401,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:
@@ -538,6 +548,13 @@ def prowler():
html_output.batch_write_data_to_file(
provider=global_provider, stats=stats
)
if mode == "sarif":
sarif_output = SARIF(
findings=finding_outputs,
file_path=f"{filename}{sarif_file_suffix}",
)
generated_outputs["regular"].append(sarif_output)
sarif_output.batch_write_data_to_file()
if getattr(args, "push_to_cloud", False):
if not ocsf_output or not getattr(ocsf_output, "file_path", None):
@@ -1154,6 +1171,19 @@ def prowler():
)
generated_outputs["compliance"].append(cis)
cis.batch_write_data_to_file()
elif compliance_name.startswith("cisa_scuba_"):
# Generate CISA SCuBA Finding Object
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
cisa_scuba = GoogleWorkspaceCISASCuBA(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(cisa_scuba)
cisa_scuba.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"

File diff suppressed because it is too large Load Diff

View File

@@ -121,6 +121,7 @@
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"defender_identity_health_issues_no_open",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_conditional_access_policy_block_elevated_insider_risk",
"entra_conditional_access_policy_block_o365_elevated_insider_risk",
"entra_identity_protection_sign_in_risk_enabled",
"entra_identity_protection_user_risk_enabled"
@@ -683,6 +684,7 @@
"entra_admin_portals_access_restriction",
"entra_conditional_access_policy_approved_client_app_required_for_mobile",
"entra_conditional_access_policy_app_enforced_restrictions",
"entra_conditional_access_policy_block_elevated_insider_risk",
"entra_conditional_access_policy_block_o365_elevated_insider_risk",
"entra_policy_guest_users_access_restrictions",
"sharepoint_external_sharing_restricted"
@@ -775,6 +777,7 @@
"defender_safelinks_policy_enabled",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_conditional_access_policy_app_enforced_restrictions",
"entra_conditional_access_policy_block_elevated_insider_risk",
"entra_conditional_access_policy_block_o365_elevated_insider_risk"
]
},

View File

@@ -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)
@@ -109,6 +110,7 @@ json_file_suffix = ".json"
json_asff_file_suffix = ".asff.json"
json_ocsf_file_suffix = ".ocsf.json"
html_file_suffix = ".html"
sarif_file_suffix = ".sarif"
default_config_file_path = (
f"{pathlib.Path(os.path.dirname(os.path.realpath(__file__)))}/config.yaml"
)
@@ -119,7 +121,7 @@ default_redteam_config_file_path = (
f"{pathlib.Path(os.path.dirname(os.path.realpath(__file__)))}/llm_config.yaml"
)
encoding_format_utf_8 = "utf-8"
available_output_formats = ["csv", "json-asff", "json-ocsf", "html"]
available_output_formats = ["csv", "json-asff", "json-ocsf", "html", "sarif"]
# Prowler Cloud API settings
cloud_api_base_url = os.getenv("PROWLER_CLOUD_API_BASE_URL", "https://api.prowler.com")

View File

@@ -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"

View File

@@ -0,0 +1,50 @@
### Account, Check and/or Region can be * to apply for all the cases.
### Account == <Vercel Team ID>
### Region == * (Vercel is a global service, region is always "global")
### Resources and tags are lists that can have either Regex or Keywords.
### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
### Use an alternation Regex to match one of multiple tags with "ORed" logic.
### For each check you can except Accounts, Regions, Resources and/or Tags.
########################### MUTELIST EXAMPLE ###########################
Mutelist:
Accounts:
"team_example123":
Checks:
"project_deployment_protection_enabled":
Regions:
- "*"
Resources:
- "prj_internal001"
- "prj_internal002"
Description: "Mute deployment protection check for internal-only projects"
"project_environment_*":
Regions:
- "*"
Resources:
- "prj_staging.*"
Description: "Mute all environment variable checks for staging projects"
"*":
Regions:
- "*"
Resources:
- "prj_sandbox"
Tags:
- "environment=sandbox"
Description: "Mute all checks for sandbox project with matching tag"
"*":
Checks:
"security_waf_enabled":
Regions:
- "*"
Resources:
- "prj_static.*"
Description: "Mute WAF check for static-only projects across all teams"
"*":
Regions:
- "*"
Resources:
- "*"
Tags:
- "prowler-ignore=true"
Description: "Global mute for resources tagged with prowler-ignore=true"

View File

@@ -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

View File

@@ -1094,15 +1094,10 @@ class CheckReportIAC(Check_Report):
self.resource = finding
self.resource_name = file_path
self.resource_line_range = (
(
str(finding.get("CauseMetadata", {}).get("StartLine", ""))
+ ":"
+ str(finding.get("CauseMetadata", {}).get("EndLine", ""))
)
if finding.get("CauseMetadata", {}).get("StartLine", "")
else ""
)
cause = finding.get("CauseMetadata", {})
start = cause.get("StartLine") or finding.get("StartLine")
end = cause.get("EndLine") or finding.get("EndLine")
self.resource_line_range = f"{start}:{end}" if start else ""
@dataclass
@@ -1240,6 +1235,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:
"""

View File

@@ -17,8 +17,11 @@ from prowler.providers.common.arguments import (
init_providers_parser,
validate_asff_usage,
validate_provider_arguments,
validate_sarif_usage,
)
SENSITIVE_ARGUMENTS = frozenset({"--shodan"})
class ProwlerArgumentParser:
# Set the default parser
@@ -27,10 +30,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
@@ -47,6 +50,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
@@ -147,6 +151,12 @@ Detailed documentation at https://docs.prowler.com
if not asff_is_valid:
self.parser.error(asff_error)
sarif_is_valid, sarif_error = validate_sarif_usage(
args.provider, getattr(args, "output_formats", None)
)
if not sarif_is_valid:
self.parser.error(sarif_error)
return args
def __set_default_provider__(self, args: list) -> list:

68
prowler/lib/cli/redact.py Normal file
View File

@@ -0,0 +1,68 @@
from functools import lru_cache
from importlib import import_module
from prowler.lib.logger import logger
from prowler.providers.common.provider import Provider, providers_path
REDACTED_VALUE = "REDACTED"
@lru_cache(maxsize=None)
def get_sensitive_arguments() -> frozenset:
"""Collect SENSITIVE_ARGUMENTS from all provider argument modules and the common parser."""
sensitive: set[str] = set()
# Common parser sensitive arguments (e.g., --shodan)
try:
parser_module = import_module("prowler.lib.cli.parser")
sensitive.update(getattr(parser_module, "SENSITIVE_ARGUMENTS", frozenset()))
except Exception as error:
logger.debug(f"Could not load SENSITIVE_ARGUMENTS from parser: {error}")
# Provider-specific sensitive arguments
for provider in Provider.get_available_providers():
try:
module = import_module(
f"{providers_path}.{provider}.lib.arguments.arguments"
)
sensitive.update(getattr(module, "SENSITIVE_ARGUMENTS", frozenset()))
except Exception as error:
logger.debug(f"Could not load SENSITIVE_ARGUMENTS from {provider}: {error}")
return frozenset(sensitive)
def redact_argv(argv: list[str]) -> str:
"""Redact values of sensitive CLI flags from an argument list.
Handles both ``--flag value`` and ``--flag=value`` syntax.
Returns a single joined string suitable for display.
"""
sensitive = get_sensitive_arguments()
result: list[str] = []
skip_next = False
for i, arg in enumerate(argv):
if skip_next:
result.append(REDACTED_VALUE)
skip_next = False
continue
# Handle --flag=value syntax
if "=" in arg:
flag = arg.split("=", 1)[0]
if flag in sensitive:
result.append(f"{flag}={REDACTED_VALUE}")
continue
# Handle --flag value syntax
if arg in sensitive:
result.append(arg)
# Only redact the next token if it exists and is not another flag
if i + 1 < len(argv) and not argv[i + 1].startswith("-"):
skip_next = True
continue
result.append(arg)
return " ".join(result)

View File

@@ -0,0 +1,90 @@
from prowler.config.config import timestamp
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.cisa_scuba.models import (
GoogleWorkspaceCISASCuBAModel,
)
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.finding import Finding
class GoogleWorkspaceCISASCuBA(ComplianceOutput):
"""
This class represents the Google Workspace CISA SCuBA compliance output.
Attributes:
- _data (list): A list to store transformed data from findings.
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into Google Workspace CISA SCuBA compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
compliance_name: str,
) -> None:
"""
Transforms a list of findings into Google Workspace CISA SCuBA compliance format.
Parameters:
- findings (list): A list of findings.
- compliance (Compliance): A compliance model.
- compliance_name (str): The name of the compliance model.
Returns:
- None
"""
for finding in findings:
# Get the compliance requirements for the finding
finding_requirements = finding.compliance.get(compliance_name, [])
for requirement in compliance.Requirements:
if requirement.Id in finding_requirements:
for attribute in requirement.Attributes:
compliance_row = GoogleWorkspaceCISASCuBAModel(
Provider=finding.provider,
Description=compliance.Description,
Domain=finding.account_name,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_Service=attribute.Service,
Requirements_Attributes_Type=attribute.Type,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = GoogleWorkspaceCISASCuBAModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
Domain="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_Service=attribute.Service,
Requirements_Attributes_Type=attribute.Type,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)

View File

@@ -0,0 +1,28 @@
from typing import Optional
from pydantic.v1 import BaseModel
class GoogleWorkspaceCISASCuBAModel(BaseModel):
"""
GoogleWorkspaceCISASCuBAModel generates a finding's output in Google Workspace CISA SCuBA Compliance format.
"""
Provider: str
Description: str
Domain: str
AssessmentDate: str
Requirements_Id: str
Requirements_Description: str
Requirements_Attributes_Section: Optional[str] = None
Requirements_Attributes_SubSection: Optional[str] = None
Requirements_Attributes_Service: Optional[str] = None
Requirements_Attributes_Type: Optional[str] = None
Status: str
StatusExtended: str
ResourceId: str
ResourceName: str
CheckId: str
Muted: bool
Framework: str
Name: str

View File

@@ -354,6 +354,9 @@ class Finding(BaseModel):
check_output, "resource_line_range", ""
)
output_data["framework"] = check_output.check_metadata.ServiceName
output_data["raw"] = {
"resource_line_range": output_data.get("resource_line_range", ""),
}
elif provider.type == "llm":
output_data["auth_method"] = provider.auth_method
@@ -404,6 +407,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"

View File

@@ -9,6 +9,7 @@ from prowler.config.config import (
square_logo_img,
timestamp,
)
from prowler.lib.cli.redact import redact_argv
from prowler.lib.logger import logger
from prowler.lib.outputs.output import Finding, Output
from prowler.lib.outputs.utils import parse_html_string, unroll_dict
@@ -196,7 +197,7 @@ class HTML(Output):
</div>
</li>
<li class="list-group-item">
<b>Parameters used:</b> {" ".join(sys.argv[1:]) if from_cli else ""}
<b>Parameters used:</b> {redact_argv(sys.argv[1:]) if from_cli else ""}
</li>
<li class="list-group-item">
<b>Date:</b> {timestamp.isoformat()}
@@ -1331,6 +1332,71 @@ class HTML(Output):
)
return ""
@staticmethod
def get_vercel_assessment_summary(provider: Provider) -> str:
"""
get_vercel_assessment_summary gets the HTML assessment summary for the Vercel provider
Args:
provider (Provider): the Vercel provider object
Returns:
str: HTML assessment summary for the Vercel provider
"""
try:
assessment_items = ""
team = getattr(provider.identity, "team", None)
if team:
assessment_items += f"""
<li class="list-group-item">
<b>Team:</b> {team.name} ({team.id})
</li>"""
credentials_items = """
<li class="list-group-item">
<b>Authentication:</b> API Token
</li>"""
email = getattr(provider.identity, "email", None)
if email:
credentials_items += f"""
<li class="list-group-item">
<b>Email:</b> {email}
</li>"""
username = getattr(provider.identity, "username", None)
if username:
credentials_items += f"""
<li class="list-group-item">
<b>Username:</b> {username}
</li>"""
return f"""
<div class="col-md-2">
<div class="card">
<div class="card-header">
Vercel Assessment Summary
</div>
<ul class="list-group list-group-flush">{assessment_items}
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
Vercel Credentials
</div>
<ul class="list-group list-group-flush">{credentials_items}
</ul>
</div>
</div>"""
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
return ""
@staticmethod
def get_assessment_summary(provider: Provider) -> str:
"""

View File

@@ -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:

View File

View File

@@ -0,0 +1,193 @@
from json import dump
from typing import Optional
from prowler.config.config import prowler_version
from prowler.lib.logger import logger
from prowler.lib.outputs.finding import Finding
from prowler.lib.outputs.output import Output
SARIF_SCHEMA_URL = "https://json.schemastore.org/sarif-2.1.0.json"
SARIF_VERSION = "2.1.0"
SEVERITY_TO_SARIF_LEVEL = {
"critical": "error",
"high": "error",
"medium": "warning",
"low": "note",
"informational": "note",
}
SEVERITY_TO_SECURITY_SEVERITY = {
"critical": "9.0",
"high": "7.0",
"medium": "4.0",
"low": "2.0",
"informational": "0.0",
}
class SARIF(Output):
"""Generates SARIF 2.1.0 output compatible with GitHub Code Scanning."""
def transform(self, findings: list[Finding]) -> None:
"""Transform findings into a SARIF 2.1.0 document.
Only FAIL findings that are not muted are included. Each unique
check ID produces one rule entry; multiple findings for the same
check share the rule via ruleIndex.
Args:
findings: List of Finding objects to transform.
"""
rules = {}
rule_indices = {}
results = []
for finding in findings:
if finding.status != "FAIL" or finding.muted:
continue
check_id = finding.metadata.CheckID
severity = finding.metadata.Severity.lower()
if check_id not in rules:
rule_indices[check_id] = len(rules)
rule = {
"id": check_id,
"name": finding.metadata.CheckTitle,
"shortDescription": {"text": finding.metadata.CheckTitle},
"fullDescription": {
"text": finding.metadata.Description or check_id
},
"help": {
"text": finding.metadata.Remediation.Recommendation.Text
or finding.metadata.Description
or check_id,
"markdown": self._build_help_markdown(finding, severity),
},
"defaultConfiguration": {
"level": SEVERITY_TO_SARIF_LEVEL.get(severity, "note"),
},
"properties": {
"tags": [
"security",
f"prowler/{finding.metadata.Provider}",
f"severity/{severity}",
],
"security-severity": SEVERITY_TO_SECURITY_SEVERITY.get(
severity, "0.0"
),
},
}
if finding.metadata.RelatedUrl:
rule["helpUri"] = finding.metadata.RelatedUrl
rules[check_id] = rule
rule_index = rule_indices[check_id]
result = {
"ruleId": check_id,
"ruleIndex": rule_index,
"level": SEVERITY_TO_SARIF_LEVEL.get(severity, "note"),
"message": {
"text": finding.status_extended or finding.metadata.CheckTitle
},
}
location = self._build_location(finding)
if location is not None:
result["locations"] = [location]
results.append(result)
sarif_document = {
"$schema": SARIF_SCHEMA_URL,
"version": SARIF_VERSION,
"runs": [
{
"tool": {
"driver": {
"name": "Prowler",
"version": prowler_version,
"informationUri": "https://prowler.com",
"rules": list(rules.values()),
},
},
"results": results,
},
],
}
self._data = [sarif_document]
def batch_write_data_to_file(self) -> None:
"""Write the SARIF document to the output file as JSON."""
try:
if (
getattr(self, "_file_descriptor", None)
and not self._file_descriptor.closed
and self._data
):
dump(self._data[0], self._file_descriptor, indent=2)
if self.close_file or self._from_cli:
self._file_descriptor.close()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@staticmethod
def _build_help_markdown(finding: Finding, severity: str) -> str:
"""Build a markdown help string for a SARIF rule."""
remediation = (
finding.metadata.Remediation.Recommendation.Text
or finding.metadata.Description
or finding.metadata.CheckID
)
lines = [
f"**{finding.metadata.CheckTitle}**\n",
f"| Severity | Remediation |",
f"| --- | --- |",
f"| {severity.upper()} | {remediation} |",
]
if finding.metadata.RelatedUrl:
lines.append(
f"\n[More info]({finding.metadata.RelatedUrl})"
)
return "\n".join(lines)
@staticmethod
def _build_location(finding: Finding) -> Optional[dict]:
"""Build a SARIF physicalLocation from a Finding.
Uses resource_name as the artifact URI and resource_line_range
(stored in finding.raw for IaC findings) for line range info.
Returns:
A SARIF location dict, or None if resource_name is empty.
"""
if not finding.resource_name:
return None
location = {
"physicalLocation": {
"artifactLocation": {
"uri": finding.resource_name,
},
},
}
line_range = finding.raw.get("resource_line_range", "")
if line_range and ":" in line_range:
parts = line_range.split(":")
try:
start_line = int(parts[0])
end_line = int(parts[1])
if start_line >= 1 and end_line >= 1:
location["physicalLocation"]["region"] = {
"startLine": start_line,
"endLine": end_line,
}
except (ValueError, IndexError):
pass # Malformed line range — skip region, keep location
return location

View File

@@ -9,6 +9,7 @@ from prowler.config.config import (
json_asff_file_suffix,
json_ocsf_file_suffix,
orange_color,
sarif_file_suffix,
)
from prowler.lib.logger import logger
from prowler.providers.github.models import GithubAppIdentityInfo, GithubIdentityInfo
@@ -99,6 +100,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):
@@ -199,6 +208,10 @@ def display_summary_table(
print(
f" - HTML: {output_directory}/{output_filename}{html_file_suffix}"
)
if "sarif" in output_options.output_modes:
print(
f" - SARIF: {output_directory}/{output_filename}{sarif_file_suffix}"
)
else:
print(

View File

@@ -70,3 +70,19 @@ def validate_asff_usage(
False,
f"json-asff output format is only available for the aws provider, but {provider} was selected",
)
def validate_sarif_usage(
provider: Optional[str], output_formats: Optional[Sequence[str]]
) -> tuple[bool, str]:
"""Ensure sarif output is only requested for the IaC provider."""
if not output_formats or "sarif" not in output_formats:
return (True, "")
if provider == "iac":
return (True, "")
return (
False,
f"sarif output format is only available for the iac provider, but {provider} was selected",
)

View File

@@ -307,6 +307,12 @@ class Provider(ABC):
timeout=arguments.timeout,
config_path=arguments.config_file,
fixer_config=fixer_config,
registry=arguments.registry,
image_filter=arguments.image_filter,
tag_filter=arguments.tag_filter,
max_images=arguments.max_images,
registry_insecure=arguments.registry_insecure,
registry_list_images=arguments.registry_list_images,
)
elif "mongodbatlas" in provider_class_name.lower():
provider_class(
@@ -365,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(

View File

@@ -1,3 +1,6 @@
SENSITIVE_ARGUMENTS = frozenset({"--personal-access-token", "--oauth-app-token"})
def init_parser(self):
"""Init the Github Provider CLI parser"""
github_parser = self.subparsers.add_parser(

View File

@@ -1,5 +1,7 @@
import re
SENSITIVE_ARGUMENTS = frozenset({"--personal-access-token", "--oauth-app-token"})
SCANNERS_CHOICES = [
"vuln",
"misconfig",

View File

@@ -0,0 +1,38 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_block_elevated_insider_risk",
"CheckTitle": "Conditional Access Policy blocks access for users with elevated insider risk",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "This check verifies that at least one **enabled** Conditional Access policy **blocks access** to all cloud applications for users flagged with an **elevated insider risk** level by Microsoft Purview Insider Risk Management and Adaptive Protection.",
"Risk": "Without blocking elevated insider risk users, compromised or malicious insiders retain **full access** to cloud applications. This enables data exfiltration, unauthorized modifications, and lateral movement, directly impacting **confidentiality** and **integrity**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/purview/insider-risk-management-adaptive-protection",
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/how-to-policy-insider-risk"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. In the Microsoft Entra admin center, go to Protection > Conditional Access > Policies.\n2. Click New policy.\n3. Under Users, select Include > All users.\n4. Under Target resources, select Include > All cloud apps.\n5. Under Conditions > Insider risk, select Configure > Yes, then check Elevated.\n6. Under Grant, select Block access.\n7. Set Enable policy to On and click Create.",
"Terraform": ""
},
"Recommendation": {
"Text": "Configure **Adaptive Protection** in Microsoft Purview to classify insider risk tiers, then create a Conditional Access policy that **blocks access** to all cloud apps for users with **elevated** risk. Only exclude dedicated break-glass accounts.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_block_elevated_insider_risk"
}
},
"Categories": [
"identity-access",
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check requires Microsoft 365 E5 with Microsoft Purview Insider Risk Management and Adaptive Protection configured. The insiderRiskLevels condition in Conditional Access evaluates the insider risk level assigned by Purview Adaptive Protection."
}

View File

@@ -0,0 +1,93 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
InsiderRiskLevel,
)
class entra_conditional_access_policy_block_elevated_insider_risk(Check):
"""Check if a Conditional Access policy blocks all cloud app access for elevated insider risk users.
This check verifies that at least one enabled Conditional Access policy
blocks access to all cloud applications for users with an elevated insider
risk level, as determined by Microsoft Purview Insider Risk Management
and Adaptive Protection.
- PASS: An enabled CA policy blocks all cloud app access for elevated insider risk users.
- FAIL: No enabled CA policy blocks broad cloud app access based on insider risk signals.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
report.status = "FAIL"
report.status_extended = "No Conditional Access Policy blocks access for users with elevated insider risk."
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
if not policy.conditions.application_conditions:
continue
if "All" not in policy.conditions.user_conditions.included_users:
continue
if (
"All"
not in policy.conditions.application_conditions.included_applications
):
continue
if (
ConditionalAccessGrantControl.BLOCK
not in policy.grant_controls.built_in_controls
):
continue
if policy.conditions.insider_risk_levels is None:
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
report.status = "FAIL"
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status_extended = f"Conditional Access Policy {policy.display_name} is configured in report-only mode to block all cloud apps and Microsoft Purview Adaptive Protection is not providing insider risk signals."
else:
report.status_extended = f"Conditional Access Policy {policy.display_name} is configured to block all cloud apps and Microsoft Purview Adaptive Protection is not providing insider risk signals."
continue
if policy.conditions.insider_risk_levels != InsiderRiskLevel.ELEVATED:
continue
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy {policy.display_name} reports blocking all cloud apps for elevated insider risk users but does not enforce it."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy {policy.display_name} blocks access to all cloud apps for users with elevated insider risk."
break
findings.append(report)
return findings

View File

@@ -1,3 +1,6 @@
SENSITIVE_ARGUMENTS = frozenset({"--atlas-private-key"})
def init_parser(self):
"""Initialize the MongoDB Atlas Provider CLI parser"""
mongodbatlas_parser = self.subparsers.add_parser(

View File

@@ -1,3 +1,6 @@
SENSITIVE_ARGUMENTS = frozenset({"--nhn-password"})
def init_parser(self):
"""Init the NHN Provider CLI parser"""
nhn_parser = self.subparsers.add_parser(

View File

@@ -1,5 +1,7 @@
from argparse import Namespace
SENSITIVE_ARGUMENTS = frozenset({"--os-password"})
def init_parser(self):
"""Initialize the OpenStack provider CLI parser."""

View File

@@ -111,7 +111,8 @@ class BlockStorage(OCIService):
try:
# Get availability domains for this compartment
identity_client = self._create_oci_client(
oci.identity.IdentityClient
oci.identity.IdentityClient,
config_overrides={"region": regional_client.region},
)
availability_domains = identity_client.list_availability_domains(
compartment_id=compartment.id

View File

@@ -39,7 +39,8 @@ class Filestorage(OCIService):
try:
# Get availability domains for this compartment
identity_client = self._create_oci_client(
oci.identity.IdentityClient
oci.identity.IdentityClient,
config_overrides={"region": regional_client.region},
)
availability_domains = identity_client.list_availability_domains(
compartment_id=compartment.id

View File

@@ -35,7 +35,7 @@ class Identity(OCIService):
self.__threading_call__(self.__list_dynamic_groups__)
self.__threading_call__(self.__list_domains__)
self.__threading_call__(self.__list_domain_password_policies__)
self.__get_password_policy__()
self.__threading_call__(self.__get_password_policy__)
self.__threading_call__(self.__search_root_compartment_resources__)
self.__threading_call__(self.__search_active_non_root_compartments__)
@@ -49,10 +49,9 @@ class Identity(OCIService):
Returns:
Identity client instance
"""
client_region = self.regional_clients.get(region)
if client_region:
return self._create_oci_client(oci.identity.IdentityClient)
return None
return self._create_oci_client(
oci.identity.IdentityClient, config_overrides={"region": region}
)
def __list_users__(self, regional_client):
"""
@@ -66,7 +65,7 @@ class Identity(OCIService):
if regional_client.region not in self.provider.identity.region:
return
identity_client = self._create_oci_client(oci.identity.IdentityClient)
identity_client = self.__get_client__(regional_client.region)
logger.info("Identity - Listing Users...")
@@ -316,7 +315,7 @@ class Identity(OCIService):
if regional_client.region not in self.provider.identity.region:
return
identity_client = self._create_oci_client(oci.identity.IdentityClient)
identity_client = self.__get_client__(regional_client.region)
logger.info("Identity - Listing Groups...")
@@ -359,7 +358,7 @@ class Identity(OCIService):
if regional_client.region not in self.provider.identity.region:
return
identity_client = self._create_oci_client(oci.identity.IdentityClient)
identity_client = self.__get_client__(regional_client.region)
logger.info("Identity - Listing Policies...")
@@ -404,7 +403,7 @@ class Identity(OCIService):
if regional_client.region not in self.provider.identity.region:
return
identity_client = self._create_oci_client(oci.identity.IdentityClient)
identity_client = self.__get_client__(regional_client.region)
logger.info("Identity - Listing Dynamic Groups...")
@@ -452,7 +451,7 @@ class Identity(OCIService):
if regional_client.region not in self.provider.identity.region:
return
identity_client = self._create_oci_client(oci.identity.IdentityClient)
identity_client = self.__get_client__(regional_client.region)
logger.info("Identity - Listing Identity Domains...")
@@ -549,10 +548,13 @@ class Identity(OCIService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def __get_password_policy__(self):
def __get_password_policy__(self, regional_client):
"""Get the password policy for the tenancy."""
try:
identity_client = self._create_oci_client(oci.identity.IdentityClient)
if regional_client.region not in self.provider.identity.region:
return
identity_client = self.__get_client__(regional_client.region)
logger.info("Identity - Getting Password Policy...")
@@ -584,7 +586,8 @@ class Identity(OCIService):
# Create search client using the helper method for proper authentication
search_client = self._create_oci_client(
oci.resource_search.ResourceSearchClient
oci.resource_search.ResourceSearchClient,
config_overrides={"region": regional_client.region},
)
# Query to search for resources in root compartment
@@ -631,7 +634,8 @@ class Identity(OCIService):
# Create search client using the helper method for proper authentication
search_client = self._create_oci_client(
oci.resource_search.ResourceSearchClient
oci.resource_search.ResourceSearchClient,
config_overrides={"region": regional_client.region},
)
# Query to search for active compartments in the tenancy (excluding root)

View File

View File

@@ -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
)

View File

View File

@@ -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.",
)

View File

@@ -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)),
)

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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."
}

View File

@@ -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

View File

@@ -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

View File

@@ -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."
}

View File

@@ -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

View File

@@ -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())

View File

@@ -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."
}

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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": ""
}

View File

@@ -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

View File

@@ -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

View File

@@ -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": ""
}

View File

@@ -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

View File

@@ -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": ""
}

View File

@@ -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

View File

@@ -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": ""
}

View File

@@ -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

View File

@@ -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())

View File

@@ -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": ""
}

View File

@@ -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

View File

@@ -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": ""
}

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More