Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ad98ed47e | |||
| 5306bb1133 | |||
| a7f18ec41f |
@@ -67,11 +67,6 @@ 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/*"
|
||||
@@ -107,8 +102,6 @@ mutelist:
|
||||
- any-glob-to-any-file: "tests/providers/openstack/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "prowler/providers/googleworkspace/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/providers/googleworkspace/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "prowler/providers/vercel/lib/mutelist/**"
|
||||
- any-glob-to-any-file: "tests/providers/vercel/lib/mutelist/**"
|
||||
|
||||
integration/s3:
|
||||
- changed-files:
|
||||
|
||||
@@ -177,14 +177,6 @@ modules:
|
||||
- tests/providers/llm/**
|
||||
e2e: []
|
||||
|
||||
- name: sdk-vercel
|
||||
match:
|
||||
- prowler/providers/vercel/**
|
||||
- prowler/compliance/vercel/**
|
||||
tests:
|
||||
- tests/providers/vercel/**
|
||||
e2e: []
|
||||
|
||||
# ============================================
|
||||
# SDK - Lib modules
|
||||
# ============================================
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
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.
|
||||
@@ -499,30 +499,6 @@ 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'
|
||||
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
package_json_file: ui/package.json
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
package_json_file: ui/package.json
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
|
||||
@@ -60,7 +60,6 @@ htmlcov/
|
||||
**/mcp-config.json
|
||||
**/mcpServers.json
|
||||
.mcp/
|
||||
.mcp.json
|
||||
|
||||
# AI Coding Assistants - Cursor
|
||||
.cursorignore
|
||||
|
||||
@@ -119,7 +119,6 @@ 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]
|
||||
@@ -240,21 +239,6 @@ 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:
|
||||
|
||||
@@ -6,10 +6,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Pin all unpinned dependencies to exact versions to prevent supply chain attacks and ensure reproducible builds [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469)
|
||||
- Filter RBAC role lookup by `tenant_id` to prevent cross-tenant privilege leak [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491)
|
||||
- `VALKEY_SCHEME`, `VALKEY_USERNAME`, and `VALKEY_PASSWORD` environment variables to configure Celery broker TLS/auth connection details for Valkey/ElastiCache [(#10420)](https://github.com/prowler-cloud/prowler/pull/10420)
|
||||
- `Vercel` provider support [(#10190)](https://github.com/prowler-cloud/prowler/pull/10190)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -27,7 +24,6 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
- Finding groups `check_title__icontains` resolution, `name__icontains` resource filter and `resource_group` field in `/resources` response [(#10486)](https://github.com/prowler-cloud/prowler/pull/10486)
|
||||
- Membership `post_delete` signal using raw FK ids to avoid `DoesNotExist` during cascade deletions [(#10497)](https://github.com/prowler-cloud/prowler/pull/10497)
|
||||
- Finding group resources endpoints returning false 404 when filters match no results, and `sort` parameter being ignored [(#10510)](https://github.com/prowler-cloud/prowler/pull/10510)
|
||||
- Jira integration failing with `JiraInvalidIssueTypeError` on non-English Jira instances due to hardcoded `"Task"` issue type; now dynamically fetches available issue types per project [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
|
||||
@@ -6722,7 +6722,7 @@ uuid6 = "2024.7.10"
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "master"
|
||||
resolved_reference = "6ac90eb1b58590b6f2f51645dbef17b9231053f4"
|
||||
resolved_reference = "2ddd5b3091bcdd8c7d44aba73b13c5c6f8f99e35"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import NotAuthenticated
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_json_api import filters
|
||||
from rest_framework_json_api.views import ModelViewSet
|
||||
|
||||
@@ -12,7 +12,7 @@ from api.authentication import CombinedJWTOrAPIKeyAuthentication
|
||||
from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
|
||||
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
|
||||
from api.filters import CustomDjangoFilterBackend
|
||||
from api.models import Role, UserRoleRelationship
|
||||
from api.models import Role, Tenant
|
||||
from api.rbac.permissions import HasPermissions
|
||||
|
||||
|
||||
@@ -113,22 +113,27 @@ class BaseTenantViewset(BaseViewSet):
|
||||
if request is not None:
|
||||
request.db_alias = self.db_alias
|
||||
|
||||
if request.method == "POST":
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
tenant = super().dispatch(request, *args, **kwargs)
|
||||
if isinstance(tenant, Response) and tenant.status_code == 201:
|
||||
self._create_admin_role(tenant.data["id"])
|
||||
return tenant
|
||||
else:
|
||||
with transaction.atomic(using=self.db_alias):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
with transaction.atomic(using=self.db_alias):
|
||||
tenant = super().dispatch(request, *args, **kwargs)
|
||||
|
||||
try:
|
||||
# If the request is a POST, create the admin role
|
||||
if request.method == "POST":
|
||||
isinstance(tenant, dict) and self._create_admin_role(
|
||||
tenant.data["id"]
|
||||
)
|
||||
except Exception as e:
|
||||
self._handle_creation_error(e, tenant)
|
||||
raise
|
||||
|
||||
return tenant
|
||||
finally:
|
||||
if alias_token is not None:
|
||||
reset_read_db_alias(alias_token)
|
||||
self.db_alias = MainRouter.default_db
|
||||
|
||||
def _create_admin_role(self, tenant_id):
|
||||
admin_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant_id=tenant_id,
|
||||
manage_users=True,
|
||||
@@ -139,11 +144,15 @@ class BaseTenantViewset(BaseViewSet):
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=self.request.user,
|
||||
role=admin_role,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
def _handle_creation_error(self, error, tenant):
|
||||
if tenant.data.get("id"):
|
||||
try:
|
||||
Tenant.objects.using(MainRouter.admin_db).filter(
|
||||
id=tenant.data["id"]
|
||||
).delete()
|
||||
except ObjectDoesNotExist:
|
||||
pass # Tenant might not exist, handle gracefully
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
if request.auth is None:
|
||||
|
||||
@@ -434,7 +434,6 @@ class ScanFilter(ProviderRelationshipFilterSet):
|
||||
class Meta:
|
||||
model = Scan
|
||||
fields = {
|
||||
"id": ["exact", "in"],
|
||||
"provider": ["exact", "in"],
|
||||
"name": ["exact", "icontains"],
|
||||
"started_at": ["gte", "lte"],
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
from django.db import migrations
|
||||
|
||||
import api.db_utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0086_attack_paths_cleanup_periodic_task"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="provider",
|
||||
name="provider",
|
||||
field=api.db_utils.ProviderEnumField(
|
||||
choices=[
|
||||
("aws", "AWS"),
|
||||
("azure", "Azure"),
|
||||
("gcp", "GCP"),
|
||||
("kubernetes", "Kubernetes"),
|
||||
("m365", "M365"),
|
||||
("github", "GitHub"),
|
||||
("mongodbatlas", "MongoDB Atlas"),
|
||||
("iac", "IaC"),
|
||||
("oraclecloud", "Oracle Cloud Infrastructure"),
|
||||
("alibabacloud", "Alibaba Cloud"),
|
||||
("cloudflare", "Cloudflare"),
|
||||
("openstack", "OpenStack"),
|
||||
("image", "Image"),
|
||||
("googleworkspace", "Google Workspace"),
|
||||
("vercel", "Vercel"),
|
||||
],
|
||||
default="aws",
|
||||
),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER TYPE provider ADD VALUE IF NOT EXISTS 'vercel';",
|
||||
reverse_sql=migrations.RunSQL.noop,
|
||||
),
|
||||
]
|
||||
@@ -4,11 +4,11 @@ import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import defusedxml
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.settings.social_login import SOCIALACCOUNT_PROVIDERS
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
import defusedxml
|
||||
from defusedxml import ElementTree as ET
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
@@ -295,7 +295,6 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
OPENSTACK = "openstack", _("OpenStack")
|
||||
IMAGE = "image", _("Image")
|
||||
GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace")
|
||||
VERCEL = "vercel", _("Vercel")
|
||||
|
||||
@staticmethod
|
||||
def validate_aws_uid(value):
|
||||
@@ -439,15 +438,6 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
pointer="/data/attributes/uid",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_vercel_uid(value):
|
||||
if not re.match(r"^team_[a-zA-Z0-9]{16,32}$", value):
|
||||
raise ModelValidationError(
|
||||
detail="Vercel provider ID must be a valid Vercel Team ID (e.g., team_xxxxxxxxxxxxxxxxxxxxxxxx).",
|
||||
code="vercel-uid",
|
||||
pointer="/data/attributes/uid",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_image_uid(value):
|
||||
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{2,249}$", value):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from django.db.models import QuerySet
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
from api.db_router import MainRouter
|
||||
@@ -29,17 +29,11 @@ class HasPermissions(BasePermission):
|
||||
if not required_permissions:
|
||||
return True
|
||||
|
||||
tenant_id = getattr(request, "tenant_id", None)
|
||||
if not tenant_id:
|
||||
tenant_id = request.auth.get("tenant_id") if request.auth else None
|
||||
if not tenant_id:
|
||||
return False
|
||||
|
||||
user_roles = (
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=request.user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
.filter(tenant_id=tenant_id)
|
||||
.all()
|
||||
)
|
||||
if not user_roles:
|
||||
return False
|
||||
@@ -51,17 +45,14 @@ class HasPermissions(BasePermission):
|
||||
return True
|
||||
|
||||
|
||||
def get_role(user: User, tenant_id: str) -> Role:
|
||||
def get_role(user: User) -> Optional[Role]:
|
||||
"""
|
||||
Retrieve the role assigned to the given user in the specified tenant.
|
||||
Retrieve the first role assigned to the given user.
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If the user has no role in the given tenant.
|
||||
Returns:
|
||||
The user's first Role instance if the user has any roles, otherwise None.
|
||||
"""
|
||||
role = user.roles.using(MainRouter.admin_db).filter(tenant_id=tenant_id).first()
|
||||
if role is None:
|
||||
raise PermissionDenied("User has no role in this tenant.")
|
||||
return role
|
||||
return user.roles.first()
|
||||
|
||||
|
||||
def get_providers(role: Role) -> QuerySet[Provider]:
|
||||
|
||||
@@ -372,7 +372,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -388,7 +387,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -411,7 +409,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -429,7 +426,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -1355,7 +1351,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -1371,7 +1366,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -1833,7 +1827,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -1849,7 +1842,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -1872,7 +1864,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -1890,7 +1881,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -2439,7 +2429,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -2455,7 +2444,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -2478,7 +2466,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -2496,7 +2483,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -2953,7 +2939,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -2969,7 +2954,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -2992,7 +2976,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -3010,7 +2993,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -3465,7 +3447,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -3481,7 +3462,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -3504,7 +3484,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -3522,7 +3501,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -3965,7 +3943,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -3981,7 +3958,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -4004,7 +3980,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -4022,7 +3997,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -5806,7 +5780,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -5822,7 +5795,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -5845,7 +5817,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -5863,7 +5834,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- name: filter[search]
|
||||
@@ -5985,7 +5955,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -6001,7 +5970,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -6024,7 +5992,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -6042,7 +6009,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- name: filter[search]
|
||||
@@ -6153,7 +6119,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -6169,7 +6134,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -6191,7 +6155,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -6209,7 +6172,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- name: filter[search]
|
||||
@@ -6352,7 +6314,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -6368,7 +6329,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -6391,7 +6351,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -6409,7 +6368,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -6565,7 +6523,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -6581,7 +6538,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -6604,7 +6560,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -6622,7 +6577,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -6772,7 +6726,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -6788,7 +6741,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -6810,7 +6762,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -6828,7 +6779,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- name: filter[search]
|
||||
@@ -7020,7 +6970,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -7036,7 +6985,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -7059,7 +7007,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -7077,7 +7024,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -7198,7 +7144,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -7214,7 +7159,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -7237,7 +7181,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -7255,7 +7198,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -7400,7 +7342,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -7416,7 +7357,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -7439,7 +7379,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -7457,7 +7396,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -8243,7 +8181,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -8259,7 +8196,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider__in]
|
||||
schema:
|
||||
@@ -8282,7 +8218,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -8300,7 +8235,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -8323,7 +8257,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -8339,7 +8272,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -8362,7 +8294,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -8380,7 +8311,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- name: filter[search]
|
||||
@@ -9050,7 +8980,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -9066,7 +8995,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -9089,7 +9017,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -9107,7 +9034,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -9601,7 +9527,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -9617,7 +9542,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -9640,7 +9564,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -9658,7 +9581,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -9965,7 +9887,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -9981,7 +9902,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -10004,7 +9924,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -10022,7 +9941,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -10335,7 +10253,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -10351,7 +10268,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -10374,7 +10290,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -10392,7 +10307,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -11215,7 +11129,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
* `azure` - Azure
|
||||
@@ -11231,7 +11144,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
- in: query
|
||||
name: filter[provider_type__in]
|
||||
schema:
|
||||
@@ -11254,7 +11166,6 @@ paths:
|
||||
- mongodbatlas
|
||||
- openstack
|
||||
- oraclecloud
|
||||
- vercel
|
||||
description: |-
|
||||
Multiple values may be separated by commas.
|
||||
|
||||
@@ -11272,7 +11183,6 @@ paths:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
explode: false
|
||||
style: form
|
||||
- in: query
|
||||
@@ -18553,15 +18463,6 @@ components:
|
||||
required:
|
||||
- clouds_yaml_content
|
||||
- clouds_yaml_cloud
|
||||
- type: object
|
||||
title: Vercel API Token
|
||||
properties:
|
||||
api_token:
|
||||
type: string
|
||||
description: Vercel API token for authentication. Can be scoped
|
||||
to a specific team.
|
||||
required:
|
||||
- api_token
|
||||
writeOnly: true
|
||||
required:
|
||||
- secret
|
||||
@@ -19564,7 +19465,6 @@ components:
|
||||
- openstack
|
||||
- image
|
||||
- googleworkspace
|
||||
- vercel
|
||||
type: string
|
||||
description: |-
|
||||
* `aws` - AWS
|
||||
@@ -19581,7 +19481,6 @@ components:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
x-spec-enum-id: c0d56cad8ab9abe5
|
||||
uid:
|
||||
type: string
|
||||
@@ -19702,7 +19601,6 @@ components:
|
||||
- openstack
|
||||
- image
|
||||
- googleworkspace
|
||||
- vercel
|
||||
type: string
|
||||
x-spec-enum-id: c0d56cad8ab9abe5
|
||||
description: |-
|
||||
@@ -19722,7 +19620,6 @@ components:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
uid:
|
||||
type: string
|
||||
title: Unique identifier for the provider, set by the provider
|
||||
@@ -19774,7 +19671,6 @@ components:
|
||||
- openstack
|
||||
- image
|
||||
- googleworkspace
|
||||
- vercel
|
||||
type: string
|
||||
x-spec-enum-id: c0d56cad8ab9abe5
|
||||
description: |-
|
||||
@@ -19794,7 +19690,6 @@ components:
|
||||
* `openstack` - OpenStack
|
||||
* `image` - Image
|
||||
* `googleworkspace` - Google Workspace
|
||||
* `vercel` - Vercel
|
||||
uid:
|
||||
type: string
|
||||
minLength: 3
|
||||
@@ -20644,15 +20539,6 @@ components:
|
||||
required:
|
||||
- clouds_yaml_content
|
||||
- clouds_yaml_cloud
|
||||
- type: object
|
||||
title: Vercel API Token
|
||||
properties:
|
||||
api_token:
|
||||
type: string
|
||||
description: Vercel API token for authentication. Can be scoped
|
||||
to a specific team.
|
||||
required:
|
||||
- api_token
|
||||
writeOnly: true
|
||||
required:
|
||||
- secret_type
|
||||
@@ -21069,15 +20955,6 @@ components:
|
||||
required:
|
||||
- clouds_yaml_content
|
||||
- clouds_yaml_cloud
|
||||
- type: object
|
||||
title: Vercel API Token
|
||||
properties:
|
||||
api_token:
|
||||
type: string
|
||||
description: Vercel API token for authentication. Can be scoped
|
||||
to a specific team.
|
||||
required:
|
||||
- api_token
|
||||
writeOnly: true
|
||||
required:
|
||||
- secret_type
|
||||
@@ -21504,15 +21381,6 @@ components:
|
||||
required:
|
||||
- clouds_yaml_content
|
||||
- clouds_yaml_cloud
|
||||
- type: object
|
||||
title: Vercel API Token
|
||||
properties:
|
||||
api_token:
|
||||
type: string
|
||||
description: Vercel API token for authentication. Can be scoped
|
||||
to a specific team.
|
||||
required:
|
||||
- api_token
|
||||
writeOnly: true
|
||||
required:
|
||||
- secret
|
||||
|
||||
@@ -215,21 +215,6 @@ class TestTokenSwitchTenant:
|
||||
tenant_id = tenants_fixture[0].id
|
||||
user_instance = User.objects.get(email=test_user)
|
||||
Membership.objects.create(user=user_instance, tenant_id=tenant_id)
|
||||
# Assign an admin role in the target tenant so the user can access resources
|
||||
target_role = Role.objects.create(
|
||||
name="admin",
|
||||
tenant_id=tenant_id,
|
||||
manage_users=True,
|
||||
manage_account=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user_instance, role=target_role, tenant_id=tenant_id
|
||||
)
|
||||
|
||||
# Check that using our new user's credentials we can authenticate and get the providers
|
||||
access_token, _ = get_api_tokens(client, test_user, test_password)
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
from unittest.mock import ANY, Mock, patch
|
||||
|
||||
import pytest
|
||||
from conftest import TEST_PASSWORD, TODAY
|
||||
from conftest import TODAY
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
@@ -830,66 +830,3 @@ class TestUserRoleLinkPermissions:
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCrossTenantRoleLeak:
|
||||
"""Regression tests for get_role() cross-tenant privilege leak.
|
||||
|
||||
get_role() must query admin_db (bypassing RLS) so that a user with a role
|
||||
in tenant A cannot accidentally pass role checks when authenticated against
|
||||
tenant B where they have no role.
|
||||
"""
|
||||
|
||||
def test_user_with_role_in_tenant_a_denied_in_tenant_b(self, tenants_fixture):
|
||||
"""User has admin role in tenant A, membership in tenant B but no role.
|
||||
Hitting an RBAC-protected endpoint with a tenant-B token must return 403."""
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
tenant_a = tenants_fixture[0]
|
||||
tenant_b = tenants_fixture[1]
|
||||
|
||||
user = User.objects.create_user(
|
||||
name="cross_tenant_user",
|
||||
email="cross_tenant@test.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=user, tenant=tenant_a, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=user, tenant=tenant_b, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Role only in tenant A
|
||||
role = Role.objects.create(
|
||||
name="admin",
|
||||
tenant_id=tenant_a.id,
|
||||
manage_users=True,
|
||||
manage_account=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant_a.id)
|
||||
|
||||
# Mint token scoped to tenant B (where user has NO role)
|
||||
serializer = TokenSerializer(
|
||||
data={
|
||||
"type": "tokens",
|
||||
"email": "cross_tenant@test.com",
|
||||
"password": TEST_PASSWORD,
|
||||
"tenant_id": tenant_b.id,
|
||||
}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
access_token = serializer.validated_data["access"]
|
||||
|
||||
client = APIClient()
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
|
||||
|
||||
# user-list requires manage_users permission via HasPermissions
|
||||
response = client.get(reverse("user-list"))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@@ -33,7 +33,6 @@ from prowler.providers.m365.m365_provider import M365Provider
|
||||
from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider
|
||||
from prowler.providers.openstack.openstack_provider import OpenstackProvider
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
|
||||
from prowler.providers.vercel.vercel_provider import VercelProvider
|
||||
|
||||
|
||||
class TestMergeDicts:
|
||||
@@ -129,7 +128,6 @@ class TestReturnProwlerProvider:
|
||||
(Provider.ProviderChoices.CLOUDFLARE.value, CloudflareProvider),
|
||||
(Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider),
|
||||
(Provider.ProviderChoices.IMAGE.value, ImageProvider),
|
||||
(Provider.ProviderChoices.VERCEL.value, VercelProvider),
|
||||
],
|
||||
)
|
||||
def test_return_prowler_provider(self, provider_type, expected_provider):
|
||||
@@ -220,24 +218,6 @@ class TestProwlerProviderConnectionTest:
|
||||
registry_token="tok123",
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_vercel_provider(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
"""Test connection test for Vercel provider passes team_id."""
|
||||
provider = MagicMock()
|
||||
provider.uid = "team_abcdef1234567890"
|
||||
provider.provider = Provider.ProviderChoices.VERCEL.value
|
||||
provider.secret.secret = {"api_token": "vercel_token_123"}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
prowler_provider_connection_test(provider)
|
||||
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
|
||||
api_token="vercel_token_123",
|
||||
team_id="team_abcdef1234567890",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_image_provider_no_creds(
|
||||
self, mock_return_prowler_provider
|
||||
@@ -304,10 +284,6 @@ class TestGetProwlerProviderKwargs:
|
||||
Provider.ProviderChoices.OPENSTACK.value,
|
||||
{},
|
||||
),
|
||||
(
|
||||
Provider.ProviderChoices.VERCEL.value,
|
||||
{"team_id": "provider_uid"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs):
|
||||
@@ -806,15 +782,11 @@ class TestProwlerIntegrationConnectionTest:
|
||||
}
|
||||
integration.configuration = {}
|
||||
|
||||
# Mock successful JIRA connection with projects and issue types
|
||||
# Mock successful JIRA connection with projects
|
||||
mock_connection = MagicMock()
|
||||
mock_connection.is_connected = True
|
||||
mock_connection.error = None
|
||||
mock_connection.projects = {"PROJ1": "Project 1", "PROJ2": "Project 2"}
|
||||
mock_connection.issue_types = {
|
||||
"PROJ1": ["Task", "Bug"],
|
||||
"PROJ2": ["Task", "Story"],
|
||||
}
|
||||
mock_jira_class.test_connection.return_value = mock_connection
|
||||
|
||||
# Mock rls_transaction context manager
|
||||
@@ -843,12 +815,6 @@ class TestProwlerIntegrationConnectionTest:
|
||||
"PROJ2": "Project 2",
|
||||
}
|
||||
|
||||
# Verify issue types were saved to integration configuration
|
||||
assert integration.configuration["issue_types"] == {
|
||||
"PROJ1": ["Task", "Bug"],
|
||||
"PROJ2": ["Task", "Story"],
|
||||
}
|
||||
|
||||
# Verify integration.save() was called
|
||||
integration.save.assert_called_once()
|
||||
|
||||
@@ -872,7 +838,6 @@ class TestProwlerIntegrationConnectionTest:
|
||||
mock_connection.is_connected = False
|
||||
mock_connection.error = Exception("Authentication failed: Invalid credentials")
|
||||
mock_connection.projects = {} # Empty projects when connection fails
|
||||
mock_connection.issue_types = {} # Empty issue types when connection fails
|
||||
mock_jira_class.test_connection.return_value = mock_connection
|
||||
|
||||
# Mock rls_transaction context manager
|
||||
@@ -898,9 +863,6 @@ class TestProwlerIntegrationConnectionTest:
|
||||
# Verify empty projects dict was saved to integration configuration
|
||||
assert integration.configuration["projects"] == {}
|
||||
|
||||
# Verify empty issue types dict was saved to integration configuration
|
||||
assert integration.configuration["issue_types"] == {}
|
||||
|
||||
# Verify integration.save() was called even on connection failure
|
||||
integration.save.assert_called_once()
|
||||
|
||||
@@ -919,11 +881,11 @@ class TestProwlerIntegrationConnectionTest:
|
||||
"domain": "example.atlassian.net",
|
||||
}
|
||||
integration.configuration = {
|
||||
"issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration
|
||||
"issue_types": ["Task"], # Existing configuration
|
||||
"projects": {"OLD_PROJ": "Old Project"}, # Will be overwritten
|
||||
}
|
||||
|
||||
# Mock successful JIRA connection with new projects and issue types
|
||||
# Mock successful JIRA connection with new projects
|
||||
mock_connection = MagicMock()
|
||||
mock_connection.is_connected = True
|
||||
mock_connection.error = None
|
||||
@@ -931,10 +893,6 @@ class TestProwlerIntegrationConnectionTest:
|
||||
"NEW_PROJ1": "New Project 1",
|
||||
"NEW_PROJ2": "New Project 2",
|
||||
}
|
||||
mock_connection.issue_types = {
|
||||
"NEW_PROJ1": ["Task", "Bug"],
|
||||
"NEW_PROJ2": ["Story"],
|
||||
}
|
||||
mock_jira_class.test_connection.return_value = mock_connection
|
||||
|
||||
# Mock rls_transaction context manager
|
||||
@@ -952,11 +910,8 @@ class TestProwlerIntegrationConnectionTest:
|
||||
"NEW_PROJ2": "New Project 2",
|
||||
}
|
||||
|
||||
# Verify issue types were also updated
|
||||
assert integration.configuration["issue_types"] == {
|
||||
"NEW_PROJ1": ["Task", "Bug"],
|
||||
"NEW_PROJ2": ["Story"],
|
||||
}
|
||||
# Verify other configuration fields were preserved
|
||||
assert integration.configuration["issue_types"] == ["Task"]
|
||||
|
||||
# Verify integration.save() was called
|
||||
integration.save.assert_called_once()
|
||||
|
||||
@@ -516,13 +516,6 @@ class TestTenantViewSet:
|
||||
response.json()["data"]["attributes"]["name"]
|
||||
== valid_tenant_payload["name"]
|
||||
)
|
||||
new_tenant_id = response.json()["data"]["id"]
|
||||
user = authenticated_client.user
|
||||
assert UserRoleRelationship.objects.filter(
|
||||
user=user,
|
||||
tenant_id=new_tenant_id,
|
||||
role__name="admin",
|
||||
).exists()
|
||||
|
||||
def test_tenants_invalid_create(self, authenticated_client, invalid_tenant_payload):
|
||||
response = authenticated_client.post(
|
||||
@@ -582,66 +575,22 @@ class TestTenantViewSet:
|
||||
Tenant.objects.filter(pk=kwargs.get("tenant_id")).delete()
|
||||
|
||||
delete_tenant_mock.side_effect = _delete_tenant
|
||||
# Use tenant2 where the user is OWNER
|
||||
_, tenant2, _ = tenants_fixture
|
||||
response = authenticated_client.delete(
|
||||
reverse("tenant-detail", kwargs={"pk": tenant2.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert Membership.objects.filter(tenant_id=tenant2.id).count() == 0
|
||||
# User is not deleted because it has another membership (tenant1)
|
||||
assert User.objects.count() == 1
|
||||
|
||||
@patch("api.v1.views.delete_tenant_task.apply_async")
|
||||
def test_tenants_delete_as_member_forbidden(
|
||||
self, delete_tenant_mock, authenticated_client, tenants_fixture
|
||||
):
|
||||
# tenant1: user is MEMBER, not OWNER -> should be forbidden
|
||||
tenant1, *_ = tenants_fixture
|
||||
response = authenticated_client.delete(
|
||||
reverse("tenant-detail", kwargs={"pk": tenant1.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
delete_tenant_mock.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.delete_tenant_task.apply_async")
|
||||
def test_tenants_delete_cross_tenant(
|
||||
self, delete_tenant_mock, authenticated_client, tenants_fixture
|
||||
):
|
||||
# tenant3: user has no membership -> should be 404
|
||||
_, _, tenant3 = tenants_fixture
|
||||
response = authenticated_client.delete(
|
||||
reverse("tenant-detail", kwargs={"pk": tenant3.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
delete_tenant_mock.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.delete_tenant_task.apply_async")
|
||||
def test_tenants_delete_only_removes_exclusive_users(
|
||||
self, delete_tenant_mock, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
def _delete_tenant(kwargs):
|
||||
Tenant.objects.filter(pk=kwargs.get("tenant_id")).delete()
|
||||
|
||||
delete_tenant_mock.side_effect = _delete_tenant
|
||||
_, tenant2, _ = tenants_fixture
|
||||
# extra_users adds user2 (OWNER in tenant2) and user3 (MEMBER in tenant2)
|
||||
# user2 and user3 are ONLY in tenant2, so they should be deleted
|
||||
# The test user is in tenant1 + tenant2, so should NOT be deleted
|
||||
initial_user_count = User.objects.count() # test_user + user2 + user3 = 3
|
||||
assert initial_user_count == 3
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse("tenant-detail", kwargs={"pk": tenant2.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
# user2 and user3 are deleted (no other memberships), test_user remains
|
||||
assert Tenant.objects.count() == len(tenants_fixture) - 1
|
||||
assert Membership.objects.filter(tenant_id=tenant1.id).count() == 0
|
||||
# User is not deleted because it has another membership
|
||||
assert User.objects.count() == 1
|
||||
|
||||
def test_tenants_delete_invalid(self, authenticated_client):
|
||||
response = authenticated_client.delete(
|
||||
reverse("tenant-detail", kwargs={"pk": "random_id"})
|
||||
)
|
||||
# To change if we implement RBAC
|
||||
# (user might not have permissions to see if the tenant exists or not -> 200 empty)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_tenants_list_filter_search(self, authenticated_client, tenants_fixture):
|
||||
@@ -745,6 +694,7 @@ class TestTenantViewSet:
|
||||
# Test user + 2 extra users for tenant 2
|
||||
assert len(response.json()["data"]) == 3
|
||||
|
||||
@patch("api.v1.views.TenantMembersViewSet.required_permissions", [])
|
||||
def test_tenants_list_memberships_as_member(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
@@ -857,30 +807,6 @@ class TestTenantViewSet:
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_tenants_delete_membership_cross_tenant(
|
||||
self, authenticated_client, tenants_fixture
|
||||
):
|
||||
# Create a tenant with a different user's membership
|
||||
other_tenant = Tenant.objects.create(name="Other Tenant")
|
||||
other_user = User.objects.create_user(
|
||||
name="other", password=TEST_PASSWORD, email="other@test.com"
|
||||
)
|
||||
other_membership = Membership.objects.create(
|
||||
user=other_user,
|
||||
tenant=other_tenant,
|
||||
role=Membership.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
# Authenticated user is NOT a member of other_tenant -> 404
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": other_tenant.id, "pk": other_membership.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert Membership.objects.filter(id=other_membership.id).exists()
|
||||
|
||||
def test_tenants_list_no_permissions(
|
||||
self, authenticated_client_no_permissions_rbac, tenants_fixture
|
||||
):
|
||||
@@ -1789,46 +1715,6 @@ class TestProviderViewSet:
|
||||
"min_length",
|
||||
"uid",
|
||||
),
|
||||
# Vercel UID validation - missing team_ prefix
|
||||
(
|
||||
{
|
||||
"provider": "vercel",
|
||||
"uid": "abcdef1234567890abcdef12",
|
||||
"alias": "test",
|
||||
},
|
||||
"vercel-uid",
|
||||
"uid",
|
||||
),
|
||||
# Vercel UID validation - too short after prefix
|
||||
(
|
||||
{
|
||||
"provider": "vercel",
|
||||
"uid": "team_abc123",
|
||||
"alias": "test",
|
||||
},
|
||||
"vercel-uid",
|
||||
"uid",
|
||||
),
|
||||
# Vercel UID validation - contains special characters
|
||||
(
|
||||
{
|
||||
"provider": "vercel",
|
||||
"uid": "team_abcdef-1234567890ab",
|
||||
"alias": "test",
|
||||
},
|
||||
"vercel-uid",
|
||||
"uid",
|
||||
),
|
||||
# Vercel UID validation - too long (33 chars after prefix)
|
||||
(
|
||||
{
|
||||
"provider": "vercel",
|
||||
"uid": "team_abcdefghijklmnopqrstuvwxyz1234567",
|
||||
"alias": "test",
|
||||
},
|
||||
"vercel-uid",
|
||||
"uid",
|
||||
),
|
||||
# Google Workspace UID validation - missing 'C' prefix
|
||||
(
|
||||
{
|
||||
@@ -2032,21 +1918,21 @@ class TestProviderViewSet:
|
||||
(
|
||||
"uid.icontains",
|
||||
"1",
|
||||
12,
|
||||
11,
|
||||
),
|
||||
("alias", "aws_testing_1", 1),
|
||||
("alias.icontains", "aws", 2),
|
||||
("inserted_at", TODAY, 13),
|
||||
("inserted_at", TODAY, 12),
|
||||
(
|
||||
"inserted_at.gte",
|
||||
"2024-01-01",
|
||||
13,
|
||||
12,
|
||||
),
|
||||
("inserted_at.lte", "2024-01-01", 0),
|
||||
(
|
||||
"updated_at.gte",
|
||||
"2024-01-01",
|
||||
13,
|
||||
12,
|
||||
),
|
||||
("updated_at.lte", "2024-01-01", 0),
|
||||
]
|
||||
@@ -2671,14 +2557,6 @@ class TestProviderSecretViewSet:
|
||||
"delegated_user": "admin@example.com",
|
||||
},
|
||||
),
|
||||
# Vercel with API Token
|
||||
(
|
||||
Provider.ProviderChoices.VERCEL.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"api_token": "fake-vercel-api-token-for-testing",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_provider_secrets_create_valid(
|
||||
@@ -3357,29 +3235,6 @@ class TestScanViewSet:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 3
|
||||
|
||||
def test_scan_filter_by_id_exact(self, authenticated_client, scans_fixture):
|
||||
scan1, *_ = scans_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("scan-list"),
|
||||
{"filter[id]": str(scan1.id)},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == str(scan1.id)
|
||||
|
||||
def test_scan_filter_by_id_in(self, authenticated_client, scans_fixture):
|
||||
scan1, scan2, *_ = scans_fixture
|
||||
response = authenticated_client.get(
|
||||
reverse("scan-list"),
|
||||
{"filter[id.in]": f"{scan1.id},{scan2.id}"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 2
|
||||
returned_ids = {item["id"] for item in data}
|
||||
assert returned_ids == {str(scan1.id), str(scan2.id)}
|
||||
|
||||
def test_scans_filter_state_failed(self, authenticated_client, scans_fixture):
|
||||
"""Ensure state filter matches only FAILED scans."""
|
||||
scan1, *_ = scans_fixture
|
||||
@@ -8242,8 +8097,6 @@ class TestUserRoleRelationshipViewSet:
|
||||
manage_scans=False,
|
||||
unlimited_visibility=False,
|
||||
)
|
||||
# Assign the role to the user
|
||||
UserRoleRelationship.objects.create(user=user, role=only_role, tenant=tenant)
|
||||
|
||||
# Switch token to this tenant
|
||||
serializer = TokenSerializer(
|
||||
|
||||
@@ -39,7 +39,6 @@ if TYPE_CHECKING:
|
||||
)
|
||||
from prowler.providers.openstack.openstack_provider import OpenstackProvider
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
|
||||
from prowler.providers.vercel.vercel_provider import VercelProvider
|
||||
|
||||
|
||||
class CustomOAuth2Client(OAuth2Client):
|
||||
@@ -95,7 +94,6 @@ def return_prowler_provider(
|
||||
| MongodbatlasProvider
|
||||
| OpenstackProvider
|
||||
| OraclecloudProvider
|
||||
| VercelProvider
|
||||
):
|
||||
"""Return the Prowler provider class based on the given provider type.
|
||||
|
||||
@@ -177,10 +175,6 @@ def return_prowler_provider(
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
|
||||
prowler_provider = ImageProvider
|
||||
case Provider.ProviderChoices.VERCEL.value:
|
||||
from prowler.providers.vercel.vercel_provider import VercelProvider
|
||||
|
||||
prowler_provider = VercelProvider
|
||||
case _:
|
||||
raise ValueError(f"Provider type {provider.provider} not supported")
|
||||
return prowler_provider
|
||||
@@ -241,11 +235,6 @@ def get_prowler_provider_kwargs(
|
||||
# clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
|
||||
# in the provider itself, so it's not needed here.
|
||||
pass
|
||||
elif provider.provider == Provider.ProviderChoices.VERCEL.value:
|
||||
prowler_provider_kwargs = {
|
||||
**prowler_provider_kwargs,
|
||||
"team_id": provider.uid,
|
||||
}
|
||||
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
|
||||
# Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or
|
||||
# a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest").
|
||||
@@ -292,7 +281,6 @@ def initialize_prowler_provider(
|
||||
| MongodbatlasProvider
|
||||
| OpenstackProvider
|
||||
| OraclecloudProvider
|
||||
| VercelProvider
|
||||
):
|
||||
"""Initialize a Prowler provider instance based on the given provider type.
|
||||
|
||||
@@ -344,13 +332,6 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
|
||||
"raise_on_exception": False,
|
||||
}
|
||||
return prowler_provider.test_connection(**openstack_kwargs)
|
||||
elif provider.provider == Provider.ProviderChoices.VERCEL.value:
|
||||
vercel_kwargs = {
|
||||
**prowler_provider_kwargs,
|
||||
"team_id": provider.uid,
|
||||
"raise_on_exception": False,
|
||||
}
|
||||
return prowler_provider.test_connection(**vercel_kwargs)
|
||||
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
|
||||
image_kwargs = {
|
||||
"image": provider.uid,
|
||||
@@ -434,12 +415,8 @@ def prowler_integration_connection_test(integration: Integration) -> Connection:
|
||||
raise_on_exception=False,
|
||||
)
|
||||
project_keys = jira_connection.projects if jira_connection.is_connected else {}
|
||||
issue_types = (
|
||||
jira_connection.issue_types if jira_connection.is_connected else {}
|
||||
)
|
||||
with rls_transaction(str(integration.tenant_id)):
|
||||
integration.configuration["projects"] = project_keys
|
||||
integration.configuration["issue_types"] = issue_types
|
||||
integration.save()
|
||||
return jira_connection
|
||||
elif integration.integration_type == Integration.IntegrationChoices.SLACK:
|
||||
|
||||
@@ -69,10 +69,8 @@ class SecurityHubConfigSerializer(BaseValidateSerializer):
|
||||
|
||||
class JiraConfigSerializer(BaseValidateSerializer):
|
||||
domain = serializers.CharField(read_only=True)
|
||||
issue_types = serializers.DictField(
|
||||
read_only=True,
|
||||
child=serializers.ListField(child=serializers.CharField()),
|
||||
default={},
|
||||
issue_types = serializers.ListField(
|
||||
read_only=True, child=serializers.CharField(), default=["Task"]
|
||||
)
|
||||
projects = serializers.DictField(read_only=True)
|
||||
|
||||
|
||||
@@ -404,17 +404,6 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
"required": ["clouds_yaml_content", "clouds_yaml_cloud"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Vercel API Token",
|
||||
"properties": {
|
||||
"api_token": {
|
||||
"type": "string",
|
||||
"description": "Vercel API token for authentication. Can be scoped to a specific team.",
|
||||
},
|
||||
},
|
||||
"required": ["api_token"],
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1573,8 +1573,6 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
|
||||
serializer = OpenStackCloudsYamlProviderSecret(data=secret)
|
||||
elif provider_type == Provider.ProviderChoices.IMAGE.value:
|
||||
serializer = ImageProviderSecret(data=secret)
|
||||
elif provider_type == Provider.ProviderChoices.VERCEL.value:
|
||||
serializer = VercelProviderSecret(data=secret)
|
||||
else:
|
||||
raise serializers.ValidationError(
|
||||
{"provider": f"Provider type not supported {provider_type}"}
|
||||
@@ -1781,13 +1779,6 @@ class ImageProviderSecret(serializers.Serializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class VercelProviderSecret(serializers.Serializer):
|
||||
api_token = serializers.CharField()
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class AlibabaCloudProviderSecret(serializers.Serializer):
|
||||
access_key_id = serializers.CharField()
|
||||
access_key_secret = serializers.CharField()
|
||||
@@ -2722,11 +2713,11 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
|
||||
)
|
||||
config_serializer = JiraConfigSerializer
|
||||
# Create non-editable configuration for JIRA integration
|
||||
# issue_types will be populated per project when connection is tested
|
||||
default_jira_issue_types = ["Task"]
|
||||
configuration.update(
|
||||
{
|
||||
"projects": {},
|
||||
"issue_types": {},
|
||||
"issue_types": default_jira_issue_types,
|
||||
"domain": credentials.get("domain"),
|
||||
}
|
||||
)
|
||||
@@ -2941,25 +2932,13 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
return representation
|
||||
|
||||
|
||||
class IntegrationJiraIssueTypesSerializer(BaseSerializerV1):
|
||||
"""
|
||||
Serializer for Jira issue types response.
|
||||
"""
|
||||
|
||||
project_key = serializers.CharField(read_only=True)
|
||||
issue_types = serializers.ListField(child=serializers.CharField(), read_only=True)
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "jira-issue-types"
|
||||
|
||||
|
||||
class IntegrationJiraDispatchSerializer(BaseSerializerV1):
|
||||
"""
|
||||
Serializer for dispatching findings to JIRA integration.
|
||||
"""
|
||||
|
||||
project_key = serializers.CharField(required=True)
|
||||
issue_type = serializers.CharField(required=True)
|
||||
issue_type = serializers.ChoiceField(required=True, choices=["Task"])
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "integrations-jira-dispatches"
|
||||
@@ -2988,23 +2967,6 @@ class IntegrationJiraDispatchSerializer(BaseSerializerV1):
|
||||
}
|
||||
)
|
||||
|
||||
issue_type = attrs.get("issue_type")
|
||||
available_issue_types = integration_instance.configuration.get(
|
||||
"issue_types", {}
|
||||
)
|
||||
# Handle old format where issue_types was a flat list (e.g., ["Task"])
|
||||
if not isinstance(available_issue_types, dict):
|
||||
available_issue_types = {}
|
||||
project_issue_types = available_issue_types.get(project_key, [])
|
||||
if project_issue_types and issue_type not in project_issue_types:
|
||||
raise ValidationError(
|
||||
{
|
||||
"issue_type": f"The issue type '{issue_type}' is not available for project '{project_key}'. "
|
||||
f"Available types: {', '.join(project_issue_types)}. "
|
||||
"Refresh the connection if this is an error."
|
||||
}
|
||||
)
|
||||
|
||||
return validated_attrs
|
||||
|
||||
|
||||
|
||||
@@ -207,7 +207,6 @@ from api.rls import Tenant
|
||||
from api.utils import (
|
||||
CustomOAuth2Client,
|
||||
get_findings_metadata_no_aggregations,
|
||||
initialize_prowler_integration,
|
||||
initialize_prowler_provider,
|
||||
validate_invitation,
|
||||
)
|
||||
@@ -236,7 +235,6 @@ from api.v1.serializers import (
|
||||
FindingsSeverityOverTimeSerializer,
|
||||
IntegrationCreateSerializer,
|
||||
IntegrationJiraDispatchSerializer,
|
||||
IntegrationJiraIssueTypesSerializer,
|
||||
IntegrationSerializer,
|
||||
IntegrationUpdateSerializer,
|
||||
InvitationAcceptSerializer,
|
||||
@@ -949,12 +947,7 @@ class UserViewSet(BaseUserViewset):
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
if self.request.user.is_authenticated:
|
||||
tenant_id = getattr(self.request, "tenant_id", None)
|
||||
if tenant_id:
|
||||
try:
|
||||
context["role"] = get_role(self.request.user, tenant_id)
|
||||
except PermissionDenied:
|
||||
context["role"] = None
|
||||
context["role"] = get_role(self.request.user)
|
||||
return context
|
||||
|
||||
@action(detail=False, methods=["get"], url_name="me")
|
||||
@@ -1236,44 +1229,28 @@ class TenantViewSet(BaseTenantViewset):
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(
|
||||
**serializer.validated_data
|
||||
)
|
||||
Membership.objects.using(MainRouter.admin_db).create(
|
||||
tenant = serializer.save()
|
||||
Membership.objects.create(
|
||||
user=self.request.user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
serializer.instance = tenant
|
||||
return Response(data=serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
tenant = self.get_object()
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
# Only owners can delete a tenant
|
||||
membership = Membership.objects.filter(user=request.user, tenant=tenant).first()
|
||||
if not membership or membership.role != Membership.RoleChoices.OWNER:
|
||||
raise PermissionDenied("Only owners can delete a tenant.")
|
||||
# This will perform validation and raise a 404 if the tenant does not exist
|
||||
tenant_id = kwargs.get("pk")
|
||||
get_object_or_404(Tenant, id=tenant_id)
|
||||
|
||||
with transaction.atomic():
|
||||
# Collect user IDs from this tenant's memberships before deleting them
|
||||
tenant_user_ids = set(
|
||||
Membership.objects.using(MainRouter.admin_db)
|
||||
.filter(tenant_id=tenant_id)
|
||||
.values_list("user_id", flat=True)
|
||||
)
|
||||
|
||||
# Delete memberships for this tenant
|
||||
# Delete memberships
|
||||
Membership.objects.using(MainRouter.admin_db).filter(
|
||||
tenant_id=tenant_id
|
||||
).delete()
|
||||
|
||||
# Delete only users that were exclusively in this tenant
|
||||
if tenant_user_ids:
|
||||
User.objects.using(MainRouter.admin_db).filter(
|
||||
id__in=tenant_user_ids, membership__isnull=True
|
||||
).delete()
|
||||
|
||||
# Delete tenant data in background
|
||||
# Delete users without memberships
|
||||
User.objects.using(MainRouter.admin_db).filter(
|
||||
membership__isnull=True
|
||||
).delete()
|
||||
# Delete tenant in batches
|
||||
delete_tenant_task.apply_async(kwargs={"tenant_id": tenant_id})
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1338,12 +1315,8 @@ class TenantMembersViewSet(BaseTenantViewset):
|
||||
http_method_names = ["get", "delete"]
|
||||
serializer_class = MembershipSerializer
|
||||
queryset = Membership.objects.none()
|
||||
# Authorization is handled by get_requesting_membership (owner/member checks),
|
||||
# not by RBAC, since the target tenant differs from the JWT tenant.
|
||||
required_permissions = []
|
||||
|
||||
def set_required_permissions(self):
|
||||
self.required_permissions = []
|
||||
# RBAC required permissions
|
||||
required_permissions = [Permissions.MANAGE_ACCOUNT]
|
||||
|
||||
def get_queryset(self):
|
||||
tenant = self.get_tenant()
|
||||
@@ -1356,10 +1329,8 @@ class TenantMembersViewSet(BaseTenantViewset):
|
||||
|
||||
def get_tenant(self):
|
||||
tenant_id = self.kwargs.get("tenant_pk")
|
||||
return get_object_or_404(
|
||||
Tenant.objects.filter(membership__user=self.request.user),
|
||||
id=tenant_id,
|
||||
)
|
||||
tenant = get_object_or_404(Tenant, id=tenant_id)
|
||||
return tenant
|
||||
|
||||
def get_requesting_membership(self, tenant):
|
||||
try:
|
||||
@@ -1446,7 +1417,7 @@ class ProviderGroupViewSet(BaseRLSViewSet):
|
||||
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
user_roles = get_role(self.request.user)
|
||||
# Check if any of the user's roles have UNLIMITED_VISIBILITY
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all provider groups
|
||||
@@ -1615,7 +1586,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
user_roles = get_role(self.request.user)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all providers
|
||||
queryset = Provider.objects.filter(tenant_id=self.request.tenant_id)
|
||||
@@ -1870,7 +1841,7 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
self.required_permissions = [Permissions.MANAGE_SCANS]
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
user_roles = get_role(self.request.user)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all scans
|
||||
queryset = Scan.objects.filter(tenant_id=self.request.tenant_id)
|
||||
@@ -2521,7 +2492,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
|
||||
return super().get_serializer_class()
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
user_roles = get_role(self.request.user)
|
||||
base_queryset = AttackPathsScan.objects.filter(tenant_id=self.request.tenant_id)
|
||||
|
||||
if user_roles.unlimited_visibility:
|
||||
@@ -2858,7 +2829,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
required_permissions = []
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
user_roles = get_role(self.request.user)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all scans
|
||||
queryset = Resource.all_objects.filter(tenant_id=self.request.tenant_id)
|
||||
@@ -3480,7 +3451,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
tenant_id = self.request.tenant_id
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
user_roles = get_role(self.request.user)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all findings
|
||||
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
|
||||
@@ -4083,9 +4054,9 @@ class RoleViewSet(BaseRLSViewSet):
|
||||
)
|
||||
)
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
user_role = get_role(request.user, request.tenant_id)
|
||||
user_role = get_role(request.user)
|
||||
# If the user is the owner of the role, the manage_account field is not editable
|
||||
if kwargs["pk"] == str(user_role.id):
|
||||
if user_role and kwargs["pk"] == str(user_role.id):
|
||||
request.data["manage_account"] = str(user_role.manage_account).lower()
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@@ -4341,7 +4312,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
|
||||
required_permissions = []
|
||||
|
||||
def get_queryset(self):
|
||||
role = get_role(self.request.user, self.request.tenant_id)
|
||||
role = get_role(self.request.user)
|
||||
unlimited_visibility = getattr(
|
||||
role, Permissions.UNLIMITED_VISIBILITY.value, False
|
||||
)
|
||||
@@ -4383,7 +4354,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
|
||||
|
||||
def _compliance_summaries_queryset(self, scan_id):
|
||||
"""Return pre-aggregated summaries constrained by RBAC visibility."""
|
||||
role = get_role(self.request.user, self.request.tenant_id)
|
||||
role = get_role(self.request.user)
|
||||
unlimited_visibility = getattr(
|
||||
role, Permissions.UNLIMITED_VISIBILITY.value, False
|
||||
)
|
||||
@@ -4925,7 +4896,7 @@ class OverviewViewSet(BaseRLSViewSet):
|
||||
required_permissions = []
|
||||
|
||||
def get_queryset(self):
|
||||
role = get_role(self.request.user, self.request.tenant_id)
|
||||
role = get_role(self.request.user)
|
||||
providers = get_providers(role)
|
||||
|
||||
if not role.unlimited_visibility:
|
||||
@@ -6098,7 +6069,7 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
allowed_providers = None
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
user_roles = get_role(self.request.user)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all integrations
|
||||
queryset = Integration.objects.filter(tenant_id=self.request.tenant_id)
|
||||
@@ -6161,7 +6132,7 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
queryset = Finding.all_objects.all()
|
||||
serializer_class = IntegrationJiraDispatchSerializer
|
||||
http_method_names = ["get", "post"]
|
||||
http_method_names = ["post"]
|
||||
filter_backends = [CustomDjangoFilterBackend]
|
||||
filterset_class = IntegrationJiraFindingsFilter
|
||||
# RBAC required permissions
|
||||
@@ -6171,23 +6142,9 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
def create(self, request, *args, **kwargs):
|
||||
raise MethodNotAllowed(method="POST")
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
def list(self, request, *args, **kwargs):
|
||||
raise MethodNotAllowed(method="GET")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "issue_types":
|
||||
return IntegrationJiraIssueTypesSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
def get_filter_backends(self):
|
||||
if self.action == "issue_types":
|
||||
return []
|
||||
return super().get_filter_backends()
|
||||
|
||||
def get_queryset(self):
|
||||
tenant_id = self.request.tenant_id
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
user_roles = get_role(self.request.user)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all findings
|
||||
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
|
||||
@@ -6199,65 +6156,6 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
|
||||
return queryset
|
||||
|
||||
@extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Get available issue types for a Jira project",
|
||||
description="Fetch the available issue types from Jira for a given project key and update the integration configuration.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="project_key",
|
||||
type=str,
|
||||
location=OpenApiParameter.QUERY,
|
||||
required=True,
|
||||
description="The Jira project key to fetch issue types for.",
|
||||
),
|
||||
],
|
||||
)
|
||||
@action(detail=False, methods=["get"], url_name="issue-types")
|
||||
def issue_types(self, request, integration_pk=None):
|
||||
integration = get_object_or_404(Integration, pk=integration_pk)
|
||||
|
||||
project_key = request.query_params.get("project_key")
|
||||
if not project_key:
|
||||
raise ValidationError({"project_key": "This query parameter is required."})
|
||||
|
||||
projects = integration.configuration.get("projects", {})
|
||||
if project_key not in projects:
|
||||
raise ValidationError(
|
||||
{
|
||||
"project_key": "The given project key is not available for this JIRA integration."
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
jira = initialize_prowler_integration(integration)
|
||||
fetched_issue_types = jira.get_available_issue_types(project_key)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to fetch issue types from Jira for integration {integration_pk}, "
|
||||
f"project {project_key}: {e}"
|
||||
)
|
||||
raise ValidationError(
|
||||
{
|
||||
"issue_types": "Failed to fetch issue types from Jira. Please check the integration connection."
|
||||
}
|
||||
)
|
||||
|
||||
# Update the integration configuration with the fetched issue types
|
||||
issue_types_config = integration.configuration.get("issue_types", {})
|
||||
if not isinstance(issue_types_config, dict):
|
||||
issue_types_config = {}
|
||||
issue_types_config[project_key] = fetched_issue_types
|
||||
|
||||
with rls_transaction(str(integration.tenant_id), using="default"):
|
||||
integration.configuration["issue_types"] = issue_types_config
|
||||
integration.save(using="default")
|
||||
|
||||
serializer = IntegrationJiraIssueTypesSerializer(
|
||||
{"project_key": project_key, "issue_types": fetched_issue_types}
|
||||
)
|
||||
return Response(data=serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=["post"], url_name="dispatches")
|
||||
def dispatches(self, request, integration_pk=None):
|
||||
get_object_or_404(Integration, pk=integration_pk)
|
||||
@@ -6929,7 +6827,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
def get_queryset(self):
|
||||
"""Get the base FindingGroupDailySummary queryset with RLS filtering."""
|
||||
tenant_id = self.request.tenant_id
|
||||
role = get_role(self.request.user, self.request.tenant_id)
|
||||
role = get_role(self.request.user)
|
||||
queryset = FindingGroupDailySummary.objects.filter(tenant_id=tenant_id)
|
||||
|
||||
if not role.unlimited_visibility:
|
||||
@@ -6939,7 +6837,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
def _get_finding_queryset(self):
|
||||
"""Get the Finding queryset for resources drill-down (with RBAC)."""
|
||||
role = get_role(self.request.user, self.request.tenant_id)
|
||||
role = get_role(self.request.user)
|
||||
providers = get_providers(role)
|
||||
|
||||
tenant_id = self.request.tenant_id
|
||||
|
||||
@@ -565,12 +565,6 @@ def providers_fixture(tenants_fixture):
|
||||
alias="googleworkspace_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider13 = Provider.objects.create(
|
||||
provider="vercel",
|
||||
uid="team_abcdef1234567890ab",
|
||||
alias="vercel_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
return (
|
||||
provider1,
|
||||
@@ -585,7 +579,6 @@ def providers_fixture(tenants_fixture):
|
||||
provider10,
|
||||
provider11,
|
||||
provider12,
|
||||
provider13,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -274,8 +274,7 @@
|
||||
{
|
||||
"group": "Image",
|
||||
"pages": [
|
||||
"user-guide/providers/image/getting-started-image",
|
||||
"user-guide/providers/image/authentication"
|
||||
"user-guide/providers/image/getting-started-image"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -297,13 +296,6 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool
|
||||
|----------|------------|------------------------|
|
||||
| Prowler Hub | 10 tools | No |
|
||||
| Prowler Documentation | 2 tools | No |
|
||||
| Prowler Cloud/App | 29 tools | Yes |
|
||||
| Prowler Cloud/App | 27 tools | Yes |
|
||||
|
||||
## Tool Naming Convention
|
||||
|
||||
@@ -60,7 +60,6 @@ Tools for searching, viewing, and analyzing cloud resources discovered by Prowle
|
||||
|
||||
- **`prowler_app_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags)
|
||||
- **`prowler_app_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships
|
||||
- **`prowler_app_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads
|
||||
- **`prowler_app_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report
|
||||
|
||||
### Muting Management
|
||||
@@ -88,7 +87,6 @@ Tools for analyzing privilege escalation chains and security misconfigurations u
|
||||
- **`prowler_app_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled)
|
||||
- **`prowler_app_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters
|
||||
- **`prowler_app_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships)
|
||||
- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries
|
||||
|
||||
### Compliance Management
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ 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,22 +141,6 @@ 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>
|
||||
@@ -640,29 +624,5 @@ 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"
|
||||
|
||||
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 316 KiB |
@@ -1,50 +0,0 @@
|
||||
---
|
||||
title: "Image Authentication in Prowler"
|
||||
---
|
||||
|
||||
Prowler's Image provider enables container image security scanning using [Trivy](https://trivy.dev/). No authentication is required for public images. Prowler supports the following authentication methods for private registries:
|
||||
|
||||
* [**Basic Authentication (Environment Variables)**](https://trivy.dev/latest/docs/advanced/private-registries/docker-hub/): `REGISTRY_USERNAME` and `REGISTRY_PASSWORD`
|
||||
* [**Token-Based Authentication**](https://distribution.github.io/distribution/spec/auth/token/): `REGISTRY_TOKEN`
|
||||
* [**Manual Docker Login**](https://docs.docker.com/reference/cli/docker/login/): Existing credentials in Docker's credential store
|
||||
|
||||
Prowler uses the first available method in this priority order.
|
||||
|
||||
## Basic Authentication (Environment Variables)
|
||||
|
||||
To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler passes these credentials to Trivy, which handles authentication with the registry transparently:
|
||||
|
||||
```bash
|
||||
export REGISTRY_USERNAME="myuser"
|
||||
export REGISTRY_PASSWORD="mypassword"
|
||||
|
||||
prowler image -I myregistry.io/myapp:v1.0
|
||||
```
|
||||
|
||||
Both variables must be set for this method to activate.
|
||||
|
||||
## Token-Based Authentication
|
||||
|
||||
To authenticate using a registry token (such as a bearer or OAuth2 token), set the `REGISTRY_TOKEN` environment variable. Prowler passes the token directly to Trivy:
|
||||
|
||||
```bash
|
||||
export REGISTRY_TOKEN="my-registry-token"
|
||||
|
||||
prowler image -I myregistry.io/myapp:v1.0
|
||||
```
|
||||
|
||||
This method is useful for registries that support token-based access without requiring a username and password.
|
||||
|
||||
## Manual Docker Login (Fallback)
|
||||
|
||||
If no environment variables are set, Prowler relies on existing credentials in Docker's credential store (`~/.docker/config.json`). To configure credentials manually before scanning:
|
||||
|
||||
```bash
|
||||
docker login myregistry.io
|
||||
|
||||
prowler image -I myregistry.io/myapp:v1.0
|
||||
```
|
||||
|
||||
<Note>
|
||||
This method is available in Prowler CLI only. In Prowler Cloud, use basic authentication or token-based authentication instead.
|
||||
</Note>
|
||||
@@ -9,69 +9,18 @@ Prowler's Image provider enables comprehensive container image security scanning
|
||||
## How It Works
|
||||
|
||||
* **Trivy integration:** Prowler leverages [Trivy](https://trivy.dev/) to scan container images for vulnerabilities, secrets, misconfigurations, and license issues.
|
||||
* **Trivy required:** Trivy must be installed and available in the system PATH before running any scan.
|
||||
* **Authentication:** No registry authentication is required for public images. For private registries, credentials can be provided via environment variables or manual `docker login`.
|
||||
* Check the [Image Authentication](/user-guide/providers/image/authentication) page for more details.
|
||||
* **Mutelist logic:** [Filtering](https://trivy.dev/latest/docs/configuration/filtering/) is handled by Trivy, not Prowler.
|
||||
* **Output formats:** Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.).
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Prowler Cloud" icon="cloud" href="#prowler-cloud">
|
||||
Scan container images using Prowler Cloud
|
||||
</Card>
|
||||
<Card title="Prowler CLI" icon="terminal" href="#prowler-cli">
|
||||
Scan container images using Prowler CLI
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Prowler Cloud
|
||||
|
||||
<VersionBadge version="5.21.0" />
|
||||
|
||||
### Supported Scanners
|
||||
|
||||
Prowler Cloud does not support scanner selection. The vulnerability, secret, and misconfiguration scanners run automatically during each scan.
|
||||
|
||||
### Step 1: Access Prowler Cloud
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Navigate to "Configuration" > "Cloud Providers"
|
||||
|
||||

|
||||
|
||||
3. Click "Add Cloud Provider"
|
||||
|
||||

|
||||
|
||||
4. Select "Container Registry"
|
||||
|
||||

|
||||
|
||||
5. Enter the container registry URL (e.g., `docker.io/myorg` or `myregistry.io`) and an optional alias, then click "Next"
|
||||
|
||||

|
||||
|
||||
### Step 2: Enter Authentication and Scan Filters
|
||||
|
||||
6. Optionally provide [authentication](/user-guide/providers/image/authentication) credentials for private registries, then configure the following scan filters to control which images are scanned:
|
||||
|
||||
* **Image filter:** A regex pattern to filter repositories by name (e.g., `^prod/.*`)
|
||||
* **Tag filter:** A regex pattern to filter tags within repositories (e.g., `^(latest|v\d+\.\d+\.\d+)$`)
|
||||
|
||||
Then click "Next"
|
||||
|
||||

|
||||
|
||||
### Step 3: Verify Connection & Start Scan
|
||||
|
||||
7. Review the provider configuration and click "Launch scan" to initiate the scan
|
||||
|
||||

|
||||
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
<VersionBadge version="5.19.0" />
|
||||
|
||||
<Note>
|
||||
The Image provider is currently available in Prowler CLI only.
|
||||
</Note>
|
||||
|
||||
### Install Trivy
|
||||
|
||||
Install Trivy using one of the following methods:
|
||||
@@ -106,7 +55,7 @@ Prowler CLI supports the following scanners:
|
||||
* [Misconfiguration](https://trivy.dev/docs/latest/guide/scanner/misconfiguration/)
|
||||
* [License](https://trivy.dev/docs/latest/guide/scanner/license/)
|
||||
|
||||
By default, vulnerability, secret, and misconfiguration scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below.
|
||||
By default, only vulnerability and secret scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below.
|
||||
|
||||
### Scan Container Images
|
||||
|
||||
@@ -163,7 +112,7 @@ Valid examples:
|
||||
|
||||
#### Specify Scanners
|
||||
|
||||
To select which scanners Trivy runs, use the `--scanners` option:
|
||||
To select which scanners Trivy runs, use the `--scanners` option. By default, Prowler enables `vuln` and `secret` scanners:
|
||||
|
||||
```bash
|
||||
# Vulnerability scanning only
|
||||
@@ -323,7 +272,7 @@ To scan images from private registries, the Image provider supports three authen
|
||||
|
||||
#### 1. Basic Authentication (Environment Variables)
|
||||
|
||||
To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler passes these credentials to Trivy, which handles authentication with the registry transparently:
|
||||
To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler automatically runs `docker login`, pulls the image, and performs a `docker logout` after the scan completes:
|
||||
|
||||
```bash
|
||||
export REGISTRY_USERNAME="myuser"
|
||||
@@ -332,7 +281,7 @@ export REGISTRY_PASSWORD="mypassword"
|
||||
prowler image -I myregistry.io/myapp:v1.0
|
||||
```
|
||||
|
||||
Both variables must be set for this method to activate.
|
||||
Both variables must be set for this method to activate. Prowler handles the full lifecycle — login, pull, scan, and cleanup — without any manual Docker commands.
|
||||
|
||||
#### 2. Token-Based Authentication
|
||||
|
||||
@@ -357,7 +306,7 @@ prowler image -I myregistry.io/myapp:v1.0
|
||||
```
|
||||
|
||||
<Note>
|
||||
Credentials provided via environment variables are only passed to the Trivy subprocess and are not persisted beyond the scan.
|
||||
When basic authentication is active (method 1), Prowler automatically logs out from all authenticated registries after the scan completes. Manual `docker login` sessions (method 3) are not affected by this cleanup.
|
||||
</Note>
|
||||
|
||||
### Troubleshooting Common Scan Errors
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,108 +0,0 @@
|
||||
---
|
||||
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 |
|
||||
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 310 KiB |
@@ -79,18 +79,6 @@ Each Jira integration provides management actions through dedicated buttons:
|
||||
| **Enable/Disable** | Toggle integration status | • Enable or disable integration<br/>| Status change takes effect immediately |
|
||||
| **Delete** | Remove integration permanently | • Permanently delete integration<br/>• Remove all configuration data | ⚠️ **Cannot be undone** - confirm before deleting |
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Issue Types with Required Custom Fields
|
||||
|
||||
Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not currently populate when creating work items. If a selected issue type enforces required fields beyond the standard set (e.g., "Team", "Epic Name"), the work item creation will fail.
|
||||
|
||||
To avoid this, select an issue type that does not require additional custom fields — **Task**, **Bug**, or **Story** typically work without restrictions. If unsure which issue types are available for a project, Prowler automatically fetches and displays them in the "Issue Type" selector when sending a finding.
|
||||
|
||||
<Note>
|
||||
Support for custom field mapping is planned for a future release.
|
||||
</Note>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection test fails
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
All notable changes to the **Prowler MCP Server** are documented in this file.
|
||||
|
||||
## [0.6.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Resource events tool to get timeline for a resource (who, what, when) [(#10412)](https://github.com/prowler-cloud/prowler/pull/10412)
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] (Prowler v5.21.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -135,48 +135,3 @@ class ResourcesMetadataResponse(BaseModel):
|
||||
regions=attributes.get("regions"),
|
||||
types=attributes.get("types"),
|
||||
)
|
||||
|
||||
|
||||
class ResourceEvent(MinimalSerializerMixin, BaseModel):
|
||||
"""A cloud API action performed on a resource.
|
||||
|
||||
Sourced from cloud provider audit logs (AWS CloudTrail, Azure Activity Logs,
|
||||
GCP Audit Logs, etc.).
|
||||
"""
|
||||
|
||||
id: str
|
||||
event_time: str
|
||||
event_name: str
|
||||
event_source: str
|
||||
actor: str
|
||||
actor_uid: str | None = None
|
||||
actor_type: str | None = None
|
||||
source_ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
request_data: dict | None = None
|
||||
response_data: dict | None = None
|
||||
error_code: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict) -> "ResourceEvent":
|
||||
"""Transform JSON:API resource event response."""
|
||||
return cls(id=data["id"], **data.get("attributes", {}))
|
||||
|
||||
|
||||
class ResourceEventsResponse(BaseModel):
|
||||
"""Response wrapper for resource events list."""
|
||||
|
||||
events: list[ResourceEvent]
|
||||
total_events: int
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, response: dict) -> "ResourceEventsResponse":
|
||||
"""Transform JSON:API response to events list."""
|
||||
data = response.get("data", [])
|
||||
events = [ResourceEvent.from_api_response(item) for item in data]
|
||||
|
||||
return cls(
|
||||
events=events,
|
||||
total_events=len(events),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any
|
||||
|
||||
from prowler_mcp_server.prowler_app.models.resources import (
|
||||
DetailedResource,
|
||||
ResourceEventsResponse,
|
||||
ResourcesListResponse,
|
||||
ResourcesMetadataResponse,
|
||||
)
|
||||
@@ -343,62 +342,3 @@ class ResourcesTools(BaseTool):
|
||||
|
||||
report = "\n".join(report_lines)
|
||||
return {"report": report}
|
||||
|
||||
async def get_resource_events(
|
||||
self,
|
||||
resource_id: str = Field(
|
||||
description="Prowler's internal UUID (v4) for the resource. Use `prowler_app_list_resources` to find the right ID, or get it from a finding's resource relationship via `prowler_app_get_finding_details`."
|
||||
),
|
||||
lookback_days: int = Field(
|
||||
default=90,
|
||||
ge=1,
|
||||
le=90,
|
||||
description="How many days back to search for events. Range: 1-90. Default: 90.",
|
||||
),
|
||||
page_size: int = Field(
|
||||
default=50,
|
||||
ge=1,
|
||||
le=50,
|
||||
description="Number of events to return. Range: 1-50. Default: 50.",
|
||||
),
|
||||
include_read_events: bool = Field(
|
||||
default=False,
|
||||
description="Include read-only API calls (e.g., Describe*, Get*, List*). Default: false (write/modify events only).",
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""Get the timeline of cloud API actions performed on a specific resource.
|
||||
|
||||
IMPORTANT: Currently only available for AWS resources. Uses CloudTrail to retrieve
|
||||
the modification history of a resource, showing who did what and when.
|
||||
|
||||
Each event includes:
|
||||
- What happened: event_name (e.g., PutBucketPolicy), event_source (e.g., s3.amazonaws.com)
|
||||
- Who did it: actor, actor_type, actor_uid
|
||||
- From where: source_ip_address, user_agent
|
||||
- What changed: request_data, response_data (full API payloads)
|
||||
- Errors: error_code, error_message (if the action failed)
|
||||
|
||||
Use cases:
|
||||
- Investigating security incidents (who modified this resource?)
|
||||
- Change tracking and audit trails
|
||||
- Understanding resource configuration drift
|
||||
- Identifying unauthorized or unexpected modifications
|
||||
|
||||
Workflows:
|
||||
1. Resource browsing: prowler_app_list_resources → find resource → this tool for event history
|
||||
2. Incident investigation: prowler_app_get_finding_details → get resource ID from finding → this tool to identify who caused the issue, what they changed, and when
|
||||
"""
|
||||
params = {
|
||||
"lookback_days": lookback_days,
|
||||
"page[size]": page_size,
|
||||
"include_read_events": include_read_events,
|
||||
}
|
||||
|
||||
clean_params = self.api_client.build_filter_params(params)
|
||||
|
||||
api_response = await self.api_client.get(
|
||||
f"/resources/{resource_id}/events", params=clean_params
|
||||
)
|
||||
events_response = ResourceEventsResponse.from_api_response(api_response)
|
||||
|
||||
return events_response.model_dump()
|
||||
|
||||
@@ -14,11 +14,8 @@ 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)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -28,11 +25,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
### 🐞 Fixed
|
||||
|
||||
- `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)
|
||||
- `JiraConnection` now includes issue types per project fetched during `test_connection`, fixing `JiraInvalidIssueTypeError` on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534)
|
||||
|
||||
### 🔐 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -46,8 +38,6 @@ 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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -69,9 +69,6 @@ from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_googleworkspace import GoogleWorkspaceCIS
|
||||
from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS
|
||||
from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import (
|
||||
GoogleWorkspaceCISASCuBA,
|
||||
)
|
||||
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.compliance import display_compliance_table
|
||||
@@ -145,7 +142,6 @@ 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():
|
||||
@@ -399,10 +395,6 @@ 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:
|
||||
@@ -1162,19 +1154,6 @@ 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/"
|
||||
|
||||
@@ -121,7 +121,6 @@
|
||||
"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"
|
||||
@@ -684,7 +683,6 @@
|
||||
"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"
|
||||
@@ -777,7 +775,6 @@
|
||||
"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,7 +65,6 @@ class Provider(str, Enum):
|
||||
ALIBABACLOUD = "alibabacloud"
|
||||
OPENSTACK = "openstack"
|
||||
IMAGE = "image"
|
||||
VERCEL = "vercel"
|
||||
|
||||
|
||||
# Providers that delegate scanning to an external tool (e.g. Trivy, promptfoo)
|
||||
|
||||
@@ -609,34 +609,3 @@ 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"
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
### 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,11 +713,6 @@ def execute(
|
||||
is_finding_muted_args["project_id"] = (
|
||||
global_provider.identity.project_id
|
||||
)
|
||||
elif global_provider.type == "vercel":
|
||||
team = getattr(global_provider.identity, "team", None)
|
||||
is_finding_muted_args["team_id"] = (
|
||||
team.id if team else global_provider.identity.user_id
|
||||
)
|
||||
for finding in check_findings:
|
||||
if global_provider.type == "cloudflare":
|
||||
is_finding_muted_args["account_id"] = finding.account_id
|
||||
|
||||
@@ -1240,50 +1240,6 @@ 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:
|
||||
"""
|
||||
|
||||
@@ -19,8 +19,6 @@ from prowler.providers.common.arguments import (
|
||||
validate_provider_arguments,
|
||||
)
|
||||
|
||||
SENSITIVE_ARGUMENTS = frozenset({"--shodan"})
|
||||
|
||||
|
||||
class ProwlerArgumentParser:
|
||||
# Set the default parser
|
||||
@@ -29,10 +27,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,vercel,dashboard,iac,image} ...",
|
||||
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ...",
|
||||
epilog="""
|
||||
Available Cloud Providers:
|
||||
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel}
|
||||
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack}
|
||||
aws AWS Provider
|
||||
azure Azure Provider
|
||||
gcp GCP Provider
|
||||
@@ -49,7 +47,6 @@ Available Cloud Providers:
|
||||
image Container Image Provider
|
||||
nhn NHN Provider (Unofficial)
|
||||
mongodbatlas MongoDB Atlas Provider (Beta)
|
||||
vercel Vercel Provider
|
||||
|
||||
Available components:
|
||||
dashboard Local dashboard
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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)
|
||||
@@ -1,90 +0,0 @@
|
||||
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)
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
@@ -404,23 +404,6 @@ 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,7 +9,6 @@ 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
|
||||
@@ -197,7 +196,7 @@ class HTML(Output):
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Parameters used:</b> {redact_argv(sys.argv[1:]) if from_cli else ""}
|
||||
<b>Parameters used:</b> {" ".join(sys.argv[1:]) if from_cli else ""}
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<b>Date:</b> {timestamp.isoformat()}
|
||||
@@ -1332,71 +1331,6 @@ 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:
|
||||
"""
|
||||
|
||||
@@ -44,11 +44,9 @@ class JiraConnection(Connection):
|
||||
Represents a Jira connection object.
|
||||
Attributes:
|
||||
projects (dict): Dictionary of projects in Jira.
|
||||
issue_types (dict): Dictionary of issue types per project key.
|
||||
"""
|
||||
|
||||
projects: dict = None
|
||||
issue_types: dict = None
|
||||
|
||||
|
||||
class MarkdownToADFConverter:
|
||||
@@ -783,20 +781,7 @@ class Jira:
|
||||
)
|
||||
projects = jira.get_projects()
|
||||
|
||||
issue_types = {}
|
||||
for project_key in projects:
|
||||
try:
|
||||
issue_types[project_key] = jira.get_available_issue_types(
|
||||
project_key
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get issue types for project {project_key}: {e}"
|
||||
)
|
||||
|
||||
return JiraConnection(
|
||||
is_connected=True, projects=projects, issue_types=issue_types
|
||||
)
|
||||
return JiraConnection(is_connected=True, projects=projects)
|
||||
except JiraNoProjectsError as no_projects_error:
|
||||
logger.error(
|
||||
f"{no_projects_error.__class__.__name__}[{no_projects_error.__traceback__.tb_lineno}]: {no_projects_error}"
|
||||
|
||||
@@ -38,8 +38,6 @@ def stdout_report(finding, color, verbose, status, fix):
|
||||
details = finding.zone_name
|
||||
if finding.check_metadata.Provider == "googleworkspace":
|
||||
details = finding.location
|
||||
if finding.check_metadata.Provider == "vercel":
|
||||
details = finding.region
|
||||
|
||||
if (verbose or fix) and (not status or finding.status in status):
|
||||
if finding.muted:
|
||||
|
||||
@@ -99,14 +99,6 @@ 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):
|
||||
|
||||
@@ -3145,17 +3145,6 @@
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"connecthealth": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"us-east-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"connectparticipant": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
@@ -5570,7 +5559,6 @@
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
@@ -5882,9 +5870,7 @@
|
||||
"cn-north-1",
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-eusc": [
|
||||
"eusc-de-east-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
@@ -7281,7 +7267,6 @@
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-5",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
@@ -8539,7 +8524,6 @@
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
@@ -8569,6 +8553,7 @@
|
||||
"notificationscontacts": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-southeast-5",
|
||||
"us-east-1"
|
||||
],
|
||||
"aws-cn": [],
|
||||
@@ -8684,14 +8669,10 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-south-1",
|
||||
"ap-south-2",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2"
|
||||
@@ -9072,7 +9053,6 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -10640,36 +10620,19 @@
|
||||
"s3vectors": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-south-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"eu-south-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
@@ -11815,38 +11778,26 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-south-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ap-southeast-4",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-6",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"ca-west-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"eu-south-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
@@ -12110,9 +12061,7 @@
|
||||
"cn-north-1",
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-eusc": [
|
||||
"eusc-de-east-1"
|
||||
],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
@@ -12335,17 +12284,6 @@
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"sustainability": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
"us-east-1",
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-eusc": [],
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
"swf": {
|
||||
"regions": {
|
||||
"aws": [
|
||||
@@ -12523,7 +12461,6 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
@@ -12537,7 +12474,6 @@
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"me-central-1",
|
||||
"mx-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
|
||||
@@ -307,12 +307,6 @@ 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(
|
||||
@@ -371,13 +365,6 @@ 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,6 +1,3 @@
|
||||
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,7 +1,5 @@
|
||||
import re
|
||||
|
||||
SENSITIVE_ARGUMENTS = frozenset({"--personal-access-token", "--oauth-app-token"})
|
||||
|
||||
SCANNERS_CHOICES = [
|
||||
"vuln",
|
||||
"misconfig",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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,6 +1,3 @@
|
||||
SENSITIVE_ARGUMENTS = frozenset({"--atlas-private-key"})
|
||||
|
||||
|
||||
def init_parser(self):
|
||||
"""Initialize the MongoDB Atlas Provider CLI parser"""
|
||||
mongodbatlas_parser = self.subparsers.add_parser(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
SENSITIVE_ARGUMENTS = frozenset({"--nhn-password"})
|
||||
|
||||
|
||||
def init_parser(self):
|
||||
"""Init the NHN Provider CLI parser"""
|
||||
nhn_parser = self.subparsers.add_parser(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from argparse import Namespace
|
||||
|
||||
SENSITIVE_ARGUMENTS = frozenset({"--os-password"})
|
||||
|
||||
|
||||
def init_parser(self):
|
||||
"""Initialize the OpenStack provider CLI parser."""
|
||||
|
||||
@@ -111,8 +111,7 @@ class BlockStorage(OCIService):
|
||||
try:
|
||||
# Get availability domains for this compartment
|
||||
identity_client = self._create_oci_client(
|
||||
oci.identity.IdentityClient,
|
||||
config_overrides={"region": regional_client.region},
|
||||
oci.identity.IdentityClient
|
||||
)
|
||||
availability_domains = identity_client.list_availability_domains(
|
||||
compartment_id=compartment.id
|
||||
|
||||
@@ -39,8 +39,7 @@ class Filestorage(OCIService):
|
||||
try:
|
||||
# Get availability domains for this compartment
|
||||
identity_client = self._create_oci_client(
|
||||
oci.identity.IdentityClient,
|
||||
config_overrides={"region": regional_client.region},
|
||||
oci.identity.IdentityClient
|
||||
)
|
||||
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.__threading_call__(self.__get_password_policy__)
|
||||
self.__get_password_policy__()
|
||||
self.__threading_call__(self.__search_root_compartment_resources__)
|
||||
self.__threading_call__(self.__search_active_non_root_compartments__)
|
||||
|
||||
@@ -49,9 +49,10 @@ class Identity(OCIService):
|
||||
Returns:
|
||||
Identity client instance
|
||||
"""
|
||||
return self._create_oci_client(
|
||||
oci.identity.IdentityClient, config_overrides={"region": region}
|
||||
)
|
||||
client_region = self.regional_clients.get(region)
|
||||
if client_region:
|
||||
return self._create_oci_client(oci.identity.IdentityClient)
|
||||
return None
|
||||
|
||||
def __list_users__(self, regional_client):
|
||||
"""
|
||||
@@ -65,7 +66,7 @@ class Identity(OCIService):
|
||||
if regional_client.region not in self.provider.identity.region:
|
||||
return
|
||||
|
||||
identity_client = self.__get_client__(regional_client.region)
|
||||
identity_client = self._create_oci_client(oci.identity.IdentityClient)
|
||||
|
||||
logger.info("Identity - Listing Users...")
|
||||
|
||||
@@ -315,7 +316,7 @@ class Identity(OCIService):
|
||||
if regional_client.region not in self.provider.identity.region:
|
||||
return
|
||||
|
||||
identity_client = self.__get_client__(regional_client.region)
|
||||
identity_client = self._create_oci_client(oci.identity.IdentityClient)
|
||||
|
||||
logger.info("Identity - Listing Groups...")
|
||||
|
||||
@@ -358,7 +359,7 @@ class Identity(OCIService):
|
||||
if regional_client.region not in self.provider.identity.region:
|
||||
return
|
||||
|
||||
identity_client = self.__get_client__(regional_client.region)
|
||||
identity_client = self._create_oci_client(oci.identity.IdentityClient)
|
||||
|
||||
logger.info("Identity - Listing Policies...")
|
||||
|
||||
@@ -403,7 +404,7 @@ class Identity(OCIService):
|
||||
if regional_client.region not in self.provider.identity.region:
|
||||
return
|
||||
|
||||
identity_client = self.__get_client__(regional_client.region)
|
||||
identity_client = self._create_oci_client(oci.identity.IdentityClient)
|
||||
|
||||
logger.info("Identity - Listing Dynamic Groups...")
|
||||
|
||||
@@ -451,7 +452,7 @@ class Identity(OCIService):
|
||||
if regional_client.region not in self.provider.identity.region:
|
||||
return
|
||||
|
||||
identity_client = self.__get_client__(regional_client.region)
|
||||
identity_client = self._create_oci_client(oci.identity.IdentityClient)
|
||||
|
||||
logger.info("Identity - Listing Identity Domains...")
|
||||
|
||||
@@ -548,13 +549,10 @@ class Identity(OCIService):
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def __get_password_policy__(self, regional_client):
|
||||
def __get_password_policy__(self):
|
||||
"""Get the password policy for the tenancy."""
|
||||
try:
|
||||
if regional_client.region not in self.provider.identity.region:
|
||||
return
|
||||
|
||||
identity_client = self.__get_client__(regional_client.region)
|
||||
identity_client = self._create_oci_client(oci.identity.IdentityClient)
|
||||
|
||||
logger.info("Identity - Getting Password Policy...")
|
||||
|
||||
@@ -586,8 +584,7 @@ class Identity(OCIService):
|
||||
|
||||
# Create search client using the helper method for proper authentication
|
||||
search_client = self._create_oci_client(
|
||||
oci.resource_search.ResourceSearchClient,
|
||||
config_overrides={"region": regional_client.region},
|
||||
oci.resource_search.ResourceSearchClient
|
||||
)
|
||||
|
||||
# Query to search for resources in root compartment
|
||||
@@ -634,8 +631,7 @@ class Identity(OCIService):
|
||||
|
||||
# Create search client using the helper method for proper authentication
|
||||
search_client = self._create_oci_client(
|
||||
oci.resource_search.ResourceSearchClient,
|
||||
config_overrides={"region": regional_client.region},
|
||||
oci.resource_search.ResourceSearchClient
|
||||
)
|
||||
|
||||
# Query to search for active compartments in the tenancy (excluding root)
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
# 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
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
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.",
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
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)),
|
||||
)
|
||||
@@ -1,177 +0,0 @@
|
||||
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
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
@@ -1,6 +0,0 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.authentication.authentication_service import (
|
||||
Authentication,
|
||||
)
|
||||
|
||||
authentication_client = Authentication(Provider.get_global_provider())
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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
|
||||
@@ -1,99 +0,0 @@
|
||||
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
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.vercel.services.deployment.deployment_service import Deployment
|
||||
|
||||
deployment_client = Deployment(Provider.get_global_provider())
|
||||