mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-14 16:50:04 +00:00
Compare commits
20 Commits
fix/ui-fin
...
feat/PROWL
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd114f10c | ||
|
|
fad845669b | ||
|
|
b28d6a4fcc | ||
|
|
cc658fc958 | ||
|
|
86b2297a5b | ||
|
|
58e5e5bb2a | ||
|
|
8bde7b6eb9 | ||
|
|
a8991f1232 | ||
|
|
4f0894dd92 | ||
|
|
5bf816ee42 | ||
|
|
42ab40d079 | ||
|
|
2ce706e474 | ||
|
|
4f86667433 | ||
|
|
4bb1e5cff7 | ||
|
|
99b80ebbd9 | ||
|
|
d18c5a8974 | ||
|
|
ab00c2dce1 | ||
|
|
765f9c72f2 | ||
|
|
de5bb94ff6 | ||
|
|
c009a2128a |
7
.github/labeler.yml
vendored
7
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
8
.github/test-impact.yml
vendored
8
.github/test-impact.yml
vendored
@@ -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
|
||||
# ============================================
|
||||
|
||||
180
.github/workflows/pr-check-compliance-mapping.yml
vendored
Normal file
180
.github/workflows/pr-check-compliance-mapping.yml
vendored
Normal 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.
|
||||
24
.github/workflows/sdk-tests.yml
vendored
24
.github/workflows/sdk-tests.yml
vendored
@@ -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'
|
||||
|
||||
16
README.md
16
README.md
@@ -119,6 +119,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
|
||||
| Google Workspace | 1 | 1 | 0 | 1 | Official | CLI |
|
||||
| OpenStack | 27 | 4 | 0 | 8 | Official | UI, API, CLI |
|
||||
| Vercel | 30 | 6 | 0 | 5 | Official | CLI |
|
||||
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
|
||||
|
||||
> [!Note]
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,6 +37,7 @@ The supported providers right now are:
|
||||
| [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI |
|
||||
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
|
||||
| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI |
|
||||
| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | CLI |
|
||||
| [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI |
|
||||
| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images | CLI, API |
|
||||
| [Google Workspace](/user-guide/providers/googleworkspace/getting-started-googleworkspace) | Official | Domains | CLI |
|
||||
|
||||
@@ -141,6 +141,22 @@ The following list includes all the GitHub checks with configurable variables th
|
||||
|--------------------------------------------|---------------------------------------------|---------|
|
||||
| `repository_inactive_not_archived` | `inactive_not_archived_days_threshold` | Integer |
|
||||
|
||||
## Vercel
|
||||
|
||||
### Configurable Checks
|
||||
The following list includes all the Vercel checks with configurable variables that can be changed in the configuration YAML file:
|
||||
|
||||
| Check Name | Value | Type |
|
||||
|-----------------------------------------------------|------------------------------------|-----------------|
|
||||
| `authentication_no_stale_tokens` | `stale_token_threshold_days` | Integer |
|
||||
| `authentication_token_not_expired` | `days_to_expire_threshold` | Integer |
|
||||
| `deployment_production_uses_stable_target` | `stable_branches` | List of Strings |
|
||||
| `domain_ssl_certificate_valid` | `days_to_expire_threshold` | Integer |
|
||||
| `project_environment_no_secrets_in_plain_type` | `secret_suffixes` | List of Strings |
|
||||
| `team_member_role_least_privilege` | `max_owner_percentage` | Integer |
|
||||
| `team_member_role_least_privilege` | `max_owners` | Integer |
|
||||
| `team_no_stale_invitations` | `stale_invitation_threshold_days` | Integer |
|
||||
|
||||
## Config YAML File Structure
|
||||
|
||||
<Note>
|
||||
@@ -624,5 +640,29 @@ github:
|
||||
# github.repository_inactive_not_archived
|
||||
inactive_not_archived_days_threshold: 180
|
||||
|
||||
# Vercel Configuration
|
||||
vercel:
|
||||
# vercel.deployment_production_uses_stable_target
|
||||
stable_branches:
|
||||
- "main"
|
||||
- "master"
|
||||
# vercel.authentication_token_not_expired & vercel.domain_ssl_certificate_valid
|
||||
days_to_expire_threshold: 7
|
||||
# vercel.authentication_no_stale_tokens
|
||||
stale_token_threshold_days: 90
|
||||
# vercel.team_no_stale_invitations
|
||||
stale_invitation_threshold_days: 30
|
||||
# vercel.team_member_role_least_privilege
|
||||
max_owner_percentage: 20
|
||||
max_owners: 3
|
||||
# vercel.project_environment_no_secrets_in_plain_type
|
||||
secret_suffixes:
|
||||
- "_KEY"
|
||||
- "_SECRET"
|
||||
- "_TOKEN"
|
||||
- "_PASSWORD"
|
||||
- "_API_KEY"
|
||||
- "_PRIVATE_KEY"
|
||||
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
137
docs/user-guide/providers/vercel/authentication.mdx
Normal file
137
docs/user-guide/providers/vercel/authentication.mdx
Normal 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".
|
||||
|
||||

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

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

|
||||
|
||||
### Provide the Team ID to Prowler
|
||||
|
||||
Export the Team ID as an environment variable:
|
||||
|
||||
```console
|
||||
export VERCEL_TOKEN="your-api-token-here"
|
||||
export VERCEL_TEAM="team_yourteamid"
|
||||
prowler vercel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `VERCEL_TOKEN` | Yes | Vercel API Bearer Token |
|
||||
| `VERCEL_TEAM` | No | Team ID or slug to scope the scan to a single team |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Create a dedicated token for Prowler** — Avoid reusing tokens shared with other integrations.
|
||||
- **Use environment variables** — Never hardcode credentials in scripts or commands.
|
||||
- **Scope tokens to specific teams** — When possible, limit token access to the team being scanned.
|
||||
- **Set token expiration** — Use time-limited tokens and rotate them regularly.
|
||||
- **Use least privilege** — Assign the Viewer role to the user creating the token unless write access is explicitly needed.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Vercel credentials not found" Error
|
||||
|
||||
This error occurs when no API Token is provided. Ensure the `VERCEL_TOKEN` environment variable is set:
|
||||
|
||||
```console
|
||||
export VERCEL_TOKEN="your-api-token-here"
|
||||
```
|
||||
|
||||
### "Invalid or expired Vercel API token" Error
|
||||
|
||||
- Verify the API Token is correct and has not expired.
|
||||
- Check that the token has not been revoked in the Vercel Dashboard under "Settings" > "Tokens".
|
||||
|
||||
### "Insufficient permissions" Error
|
||||
|
||||
- Ensure the user that created the token has at least a **Viewer** role on the target team.
|
||||
- If scanning a specific team, verify the token scope includes that team.
|
||||
|
||||
### "Team not found or not accessible" Error
|
||||
|
||||
This error occurs when the provided `VERCEL_TEAM` value does not match an accessible team. Verify the Team ID is correct:
|
||||
|
||||
1. Navigate to the team "Settings" in the Vercel Dashboard.
|
||||
2. Copy the exact **Team ID** value from the settings page.
|
||||
|
||||
### "Rate limit exceeded" Error
|
||||
|
||||
Vercel applies rate limits to API requests. Prowler automatically retries rate-limited requests up to 3 times with exponential backoff. If this error persists:
|
||||
|
||||
- Reduce the number of projects being scanned in a single run using the `--project` argument.
|
||||
- Wait a few minutes and retry the scan.
|
||||
108
docs/user-guide/providers/vercel/getting-started-vercel.mdx
Normal file
108
docs/user-guide/providers/vercel/getting-started-vercel.mdx
Normal 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 |
BIN
docs/user-guide/providers/vercel/images/vercel-create-token.png
Normal file
BIN
docs/user-guide/providers/vercel/images/vercel-create-token.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
BIN
docs/user-guide/providers/vercel/images/vercel-team-id.png
Normal file
BIN
docs/user-guide/providers/vercel/images/vercel-team-id.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
50
prowler/config/vercel_mutelist_example.yaml
Normal file
50
prowler/config/vercel_mutelist_example.yaml
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
68
prowler/lib/cli/redact.py
Normal 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)
|
||||
@@ -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)
|
||||
28
prowler/lib/outputs/compliance/cisa_scuba/models.py
Normal file
28
prowler/lib/outputs/compliance/cisa_scuba/models.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
0
prowler/lib/outputs/sarif/__init__.py
Normal file
0
prowler/lib/outputs/sarif/__init__.py
Normal file
193
prowler/lib/outputs/sarif/sarif.py
Normal file
193
prowler/lib/outputs/sarif/sarif.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import re
|
||||
|
||||
SENSITIVE_ARGUMENTS = frozenset({"--personal-access-token", "--oauth-app-token"})
|
||||
|
||||
SCANNERS_CHOICES = [
|
||||
"vuln",
|
||||
"misconfig",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from argparse import Namespace
|
||||
|
||||
SENSITIVE_ARGUMENTS = frozenset({"--os-password"})
|
||||
|
||||
|
||||
def init_parser(self):
|
||||
"""Initialize the OpenStack provider CLI parser."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
prowler/providers/vercel/__init__.py
Normal file
0
prowler/providers/vercel/__init__.py
Normal file
0
prowler/providers/vercel/exceptions/__init__.py
Normal file
0
prowler/providers/vercel/exceptions/__init__.py
Normal file
127
prowler/providers/vercel/exceptions/exceptions.py
Normal file
127
prowler/providers/vercel/exceptions/exceptions.py
Normal 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
|
||||
)
|
||||
0
prowler/providers/vercel/lib/__init__.py
Normal file
0
prowler/providers/vercel/lib/__init__.py
Normal file
0
prowler/providers/vercel/lib/arguments/__init__.py
Normal file
0
prowler/providers/vercel/lib/arguments/__init__.py
Normal file
18
prowler/providers/vercel/lib/arguments/arguments.py
Normal file
18
prowler/providers/vercel/lib/arguments/arguments.py
Normal 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.",
|
||||
)
|
||||
0
prowler/providers/vercel/lib/mutelist/__init__.py
Normal file
0
prowler/providers/vercel/lib/mutelist/__init__.py
Normal file
20
prowler/providers/vercel/lib/mutelist/mutelist.py
Normal file
20
prowler/providers/vercel/lib/mutelist/mutelist.py
Normal 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)),
|
||||
)
|
||||
0
prowler/providers/vercel/lib/service/__init__.py
Normal file
0
prowler/providers/vercel/lib/service/__init__.py
Normal file
177
prowler/providers/vercel/lib/service/service.py
Normal file
177
prowler/providers/vercel/lib/service/service.py
Normal 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
|
||||
52
prowler/providers/vercel/models.py
Normal file
52
prowler/providers/vercel/models.py
Normal 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
|
||||
0
prowler/providers/vercel/services/__init__.py
Normal file
0
prowler/providers/vercel/services/__init__.py
Normal 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())
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.authentication.authentication_client import (
|
||||
authentication_client,
|
||||
)
|
||||
|
||||
|
||||
class authentication_no_stale_tokens(Check):
|
||||
"""Check if API tokens have been used recently.
|
||||
|
||||
This class verifies whether each Vercel API token has been active within
|
||||
the configured threshold (default: 90 days). Stale tokens that remain
|
||||
unused pose a security risk as they may have been forgotten or belong
|
||||
to former team members.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Stale Token check.
|
||||
|
||||
Iterates over all tokens and checks if each token has been active
|
||||
within the configured threshold. The threshold is configurable via
|
||||
``stale_token_threshold_days`` in audit_config (default: 90 days).
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each token.
|
||||
"""
|
||||
findings = []
|
||||
now = datetime.now(timezone.utc)
|
||||
stale_threshold_days = authentication_client.audit_config.get(
|
||||
"stale_token_threshold_days", 90
|
||||
)
|
||||
stale_cutoff = now - timedelta(days=stale_threshold_days)
|
||||
|
||||
for token in authentication_client.tokens.values():
|
||||
report = CheckReportVercel(
|
||||
metadata=self.metadata(),
|
||||
resource=token,
|
||||
resource_name=token.name,
|
||||
resource_id=token.id,
|
||||
)
|
||||
|
||||
if token.active_at is None:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) has no recorded activity "
|
||||
f"and is considered stale."
|
||||
)
|
||||
elif token.active_at < stale_cutoff:
|
||||
days_inactive = (now - token.active_at).days
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) has not been used for "
|
||||
f"{days_inactive} days (last active: "
|
||||
f"{token.active_at.strftime('%Y-%m-%d %H:%M UTC')}). "
|
||||
f"Threshold is {stale_threshold_days} days."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) was last active on "
|
||||
f"{token.active_at.strftime('%Y-%m-%d %H:%M UTC')} "
|
||||
f"(within the last {stale_threshold_days} days)."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.vercel.lib.service.service import VercelService
|
||||
|
||||
|
||||
class Authentication(VercelService):
|
||||
"""Retrieve Vercel API token metadata for hygiene checks."""
|
||||
|
||||
def __init__(self, provider):
|
||||
super().__init__("Authentication", provider)
|
||||
self.tokens: dict[str, VercelAuthToken] = {}
|
||||
self._list_tokens()
|
||||
|
||||
def _list_tokens(self):
|
||||
"""List all API tokens for the authenticated user and their teams."""
|
||||
# Always fetch personal tokens (no teamId filter)
|
||||
self._fetch_tokens_for_scope(team_id=None)
|
||||
|
||||
# Also fetch tokens scoped to each team
|
||||
for tid in self._all_team_ids:
|
||||
self._fetch_tokens_for_scope(team_id=tid)
|
||||
|
||||
logger.info(f"Authentication - Found {len(self.tokens)} token(s)")
|
||||
|
||||
def _fetch_tokens_for_scope(self, team_id: str = None):
|
||||
"""Fetch tokens for a specific scope (personal or team).
|
||||
|
||||
Args:
|
||||
team_id: Team ID to fetch tokens for. None for personal tokens.
|
||||
"""
|
||||
try:
|
||||
# Always set teamId key explicitly — _get won't auto-inject when key
|
||||
# is present, and requests skips None values from query params.
|
||||
params = {"teamId": team_id}
|
||||
data = self._get("/v5/user/tokens", params=params)
|
||||
if not data:
|
||||
return
|
||||
|
||||
tokens = data.get("tokens", [])
|
||||
|
||||
for token in tokens:
|
||||
token_id = token.get("id", "")
|
||||
if not token_id or token_id in self.tokens:
|
||||
continue
|
||||
|
||||
active_at = None
|
||||
if token.get("activeAt"):
|
||||
active_at = datetime.fromtimestamp(
|
||||
token["activeAt"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
created_at = None
|
||||
if token.get("createdAt"):
|
||||
created_at = datetime.fromtimestamp(
|
||||
token["createdAt"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
expires_at = None
|
||||
if token.get("expiresAt"):
|
||||
expires_at = datetime.fromtimestamp(
|
||||
token["expiresAt"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
self.tokens[token_id] = VercelAuthToken(
|
||||
id=token_id,
|
||||
name=token.get("name", "Unnamed Token"),
|
||||
type=token.get("type"),
|
||||
active_at=active_at,
|
||||
created_at=created_at,
|
||||
expires_at=expires_at,
|
||||
scopes=token.get("scopes", []),
|
||||
origin=token.get("origin"),
|
||||
team_id=token.get("teamId") or team_id,
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
scope = f"team {team_id}" if team_id else "personal"
|
||||
logger.error(
|
||||
f"Authentication - Error listing tokens for {scope}: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class VercelAuthToken(BaseModel):
|
||||
"""Vercel API token representation."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: Optional[str] = None
|
||||
active_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
scopes: list[dict] = Field(default_factory=list)
|
||||
origin: Optional[str] = None
|
||||
team_id: Optional[str] = None
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel, Severity
|
||||
from prowler.providers.vercel.services.authentication.authentication_client import (
|
||||
authentication_client,
|
||||
)
|
||||
|
||||
|
||||
class authentication_token_not_expired(Check):
|
||||
"""Check if API tokens have not expired or are about to expire.
|
||||
|
||||
This class verifies whether each Vercel API token is still valid by
|
||||
checking its expiration date against the current time. Tokens expiring
|
||||
within a configurable threshold (default: 7 days) are flagged as
|
||||
about to expire with medium severity.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Token Expiration check.
|
||||
|
||||
Iterates over all tokens and checks if each token has expired or
|
||||
is about to expire soon. The threshold is configurable via
|
||||
``days_to_expire_threshold`` in audit_config (default: 7 days).
|
||||
Tokens without an expiration date are considered valid (no expiry set).
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each token.
|
||||
"""
|
||||
findings = []
|
||||
now = datetime.now(timezone.utc)
|
||||
days_to_expire_threshold = authentication_client.audit_config.get(
|
||||
"days_to_expire_threshold", 7
|
||||
)
|
||||
for token in authentication_client.tokens.values():
|
||||
report = CheckReportVercel(
|
||||
metadata=self.metadata(),
|
||||
resource=token,
|
||||
resource_name=token.name,
|
||||
resource_id=token.id,
|
||||
)
|
||||
|
||||
if token.expires_at is None:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) does not have an expiration "
|
||||
f"date set and is currently valid."
|
||||
)
|
||||
elif token.expires_at <= now:
|
||||
report.status = "FAIL"
|
||||
report.check_metadata.Severity = Severity.high
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) has expired "
|
||||
f"on {token.expires_at.strftime('%Y-%m-%d %H:%M UTC')}."
|
||||
)
|
||||
else:
|
||||
days_left = (token.expires_at - now).days
|
||||
if days_left <= days_to_expire_threshold:
|
||||
report.status = "FAIL"
|
||||
report.check_metadata.Severity = Severity.medium
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) is about to expire "
|
||||
f"in {days_left} days "
|
||||
f"on {token.expires_at.strftime('%Y-%m-%d %H:%M UTC')}."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Token '{token.name}' ({token.id}) is valid and expires "
|
||||
f"on {token.expires_at.strftime('%Y-%m-%d %H:%M UTC')}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.deployment.deployment_service import Deployment
|
||||
|
||||
deployment_client = Deployment(Provider.get_global_provider())
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.deployment.deployment_client import (
|
||||
deployment_client,
|
||||
)
|
||||
|
||||
|
||||
class deployment_production_uses_stable_target(Check):
|
||||
"""Check if production deployments are sourced from a stable branch.
|
||||
|
||||
This class verifies whether each Vercel production deployment originates
|
||||
from a configured stable branch rather than a feature branch. The list of
|
||||
stable branches is configurable via audit_config key ``stable_branches``
|
||||
(default: ``["main", "master"]``).
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Production Deployment Stable Target check.
|
||||
|
||||
Iterates over all deployments, filters for production targets with
|
||||
git source information, and checks if the branch is main or master.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each production deployment.
|
||||
"""
|
||||
findings = []
|
||||
for deployment in deployment_client.deployments.values():
|
||||
if deployment.target != "production":
|
||||
continue
|
||||
|
||||
if not deployment.git_source:
|
||||
continue
|
||||
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=deployment)
|
||||
|
||||
stable_branches = deployment_client.audit_config.get(
|
||||
"stable_branches", ["main", "master"]
|
||||
)
|
||||
branch = deployment.git_source.get("branch") or ""
|
||||
if branch in stable_branches:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Production deployment {deployment.name} ({deployment.id}) "
|
||||
f"is sourced from stable branch '{branch}'."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Production deployment {deployment.name} ({deployment.id}) "
|
||||
f"is sourced from branch '{branch}' instead of a "
|
||||
f"configured stable branch ({', '.join(stable_branches)})."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,103 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.vercel.lib.service.service import VercelService
|
||||
|
||||
|
||||
class Deployment(VercelService):
|
||||
"""Retrieve recent Vercel deployments."""
|
||||
|
||||
def __init__(self, provider):
|
||||
super().__init__("Deployment", provider)
|
||||
self.deployments: dict[str, VercelDeployment] = {}
|
||||
self._list_deployments()
|
||||
|
||||
def _list_deployments(self):
|
||||
"""List recent deployments across all projects."""
|
||||
try:
|
||||
params = {"limit": 100}
|
||||
# Fetch only recent deployments (first page is sufficient for security checks)
|
||||
raw_deployments = self._paginate("/v6/deployments", "deployments", params)
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
filter_projects = self.provider.filter_projects
|
||||
|
||||
for dep in raw_deployments:
|
||||
dep_id = dep.get("uid", dep.get("id", ""))
|
||||
if not dep_id or dep_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(dep_id)
|
||||
|
||||
project_id = dep.get("projectId", "")
|
||||
|
||||
# Apply project filter if specified
|
||||
if filter_projects and project_id not in filter_projects:
|
||||
project_name = dep.get("name", "")
|
||||
if project_name not in filter_projects:
|
||||
continue
|
||||
|
||||
created_at = None
|
||||
if dep.get("createdAt"):
|
||||
created_at = datetime.fromtimestamp(
|
||||
dep["createdAt"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
ready_at = None
|
||||
if dep.get("ready"):
|
||||
ready_at = datetime.fromtimestamp(
|
||||
dep["ready"] / 1000, tz=timezone.utc
|
||||
)
|
||||
|
||||
git_source = None
|
||||
meta = dep.get("meta", {}) or {}
|
||||
if meta.get("githubCommitSha") or meta.get("gitlabCommitSha"):
|
||||
git_source = {
|
||||
"commit_sha": meta.get("githubCommitSha")
|
||||
or meta.get("gitlabCommitSha"),
|
||||
"branch": meta.get("githubCommitRef")
|
||||
or meta.get("gitlabCommitRef"),
|
||||
"repo": meta.get("githubRepo") or meta.get("gitlabRepo"),
|
||||
}
|
||||
|
||||
self.deployments[dep_id] = VercelDeployment(
|
||||
id=dep_id,
|
||||
name=dep.get("name", ""),
|
||||
url=dep.get("url", ""),
|
||||
state=dep.get("state", dep.get("readyState", "")),
|
||||
target=dep.get("target"),
|
||||
created_at=created_at,
|
||||
ready_at=ready_at,
|
||||
project_id=project_id,
|
||||
project_name=dep.get("name", ""),
|
||||
team_id=dep.get("teamId") or self.provider.session.team_id,
|
||||
git_source=git_source,
|
||||
deployment_protection=dep.get("deploymentProtection"),
|
||||
)
|
||||
|
||||
logger.info(f"Deployment - Found {len(self.deployments)} deployment(s)")
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"Deployment - Error listing deployments: "
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class VercelDeployment(BaseModel):
|
||||
"""Vercel deployment representation."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
url: str = ""
|
||||
state: str = ""
|
||||
target: Optional[str] = None # "production" | "preview"
|
||||
created_at: Optional[datetime] = None
|
||||
ready_at: Optional[datetime] = None
|
||||
project_id: Optional[str] = None
|
||||
project_name: Optional[str] = None
|
||||
team_id: Optional[str] = None
|
||||
git_source: Optional[dict] = None
|
||||
deployment_protection: Optional[dict] = None
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.domain.domain_service import Domain
|
||||
|
||||
domain_client = Domain(Provider.get_global_provider())
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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
|
||||
124
prowler/providers/vercel/services/domain/domain_service.py
Normal file
124
prowler/providers/vercel/services/domain/domain_service.py
Normal 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
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "vercel",
|
||||
"CheckID": "domain_verified",
|
||||
"CheckTitle": "Vercel domains are verified",
|
||||
"CheckType": [],
|
||||
"ServiceName": "domain",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Vercel domains** are assessed for **ownership verification** status. Unverified domains may not serve traffic correctly and could indicate a pending or incomplete domain setup. Domain verification confirms that the domain owner has authorized Vercel to manage the domain.",
|
||||
"Risk": "**Unverified domains** may fail to resolve or serve content, causing **downtime** for users. An unverified domain could also indicate a stale or orphaned configuration, or a domain that was added but never properly transferred, creating potential for **domain takeover** if the ownership verification is left incomplete.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://vercel.com/docs/projects/domains"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Domains\n3. For any unverified domain, follow the verification steps shown\n4. Add the required DNS records (CNAME or A record) at your domain registrar\n5. Wait for DNS propagation and verify the domain",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Complete domain verification by configuring the required DNS records at your domain registrar. Remove any domains that are no longer needed to reduce the attack surface. Regularly audit domain configurations to ensure all domains remain verified.",
|
||||
"Url": "https://hub.prowler.com/checks/vercel/domain_verified"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"trust-boundaries"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"domain_dns_properly_configured",
|
||||
"domain_ssl_certificate_valid"
|
||||
],
|
||||
"Notes": ""
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.domain.domain_client import domain_client
|
||||
|
||||
|
||||
class domain_verified(Check):
|
||||
"""Check if domains have been verified by Vercel.
|
||||
|
||||
This class verifies whether each Vercel domain has passed ownership
|
||||
verification. Unverified domains may not function correctly and could
|
||||
indicate domain misconfiguration or hijacking attempts.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Domain Verified check.
|
||||
|
||||
Iterates over all domains and checks if each is verified.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each domain.
|
||||
"""
|
||||
findings = []
|
||||
for domain in domain_client.domains.values():
|
||||
report = CheckReportVercel(
|
||||
metadata=self.metadata(),
|
||||
resource=domain,
|
||||
resource_name=domain.name,
|
||||
resource_id=domain.id or domain.name,
|
||||
)
|
||||
|
||||
if domain.verified:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Domain {domain.name} is verified."
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Domain {domain.name} is not verified. "
|
||||
f"The domain may not be serving traffic correctly."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportVercel
|
||||
from prowler.providers.vercel.services.project.project_client import project_client
|
||||
|
||||
|
||||
class project_auto_expose_system_env_disabled(Check):
|
||||
"""Check if automatic exposure of system environment variables is disabled.
|
||||
|
||||
This class verifies whether each Vercel project has the automatic exposure
|
||||
of system environment variables disabled to prevent information leakage.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportVercel]:
|
||||
"""Execute the Vercel Project Auto Expose System Env check.
|
||||
|
||||
Iterates over all projects and checks if automatic exposure of system
|
||||
environment variables is disabled.
|
||||
|
||||
Returns:
|
||||
List[CheckReportVercel]: A list of reports for each project.
|
||||
"""
|
||||
findings = []
|
||||
for project in project_client.projects.values():
|
||||
report = CheckReportVercel(metadata=self.metadata(), resource=project)
|
||||
|
||||
if not project.auto_expose_system_envs:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} does not automatically expose "
|
||||
f"system environment variables to the build process."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Project {project.name} automatically exposes system "
|
||||
f"environment variables to the build process."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.project.project_service import Project
|
||||
|
||||
project_client = Project(Provider.get_global_provider())
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user