Compare commits

..

3 Commits

Author SHA1 Message Date
Alan Buscaglia 9ad98ed47e fix(ui): code quality improvements for findings grouped view (#10515)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-03-31 17:07:50 +02:00
Alan Buscaglia 5306bb1133 fix(ui): security and crash fixes for findings grouped view (#10514) 2026-03-31 17:04:56 +02:00
Alan Buscaglia a7f18ec41f chore: init feature branch for findings groups improvements 2026-03-30 14:20:31 +02:00
271 changed files with 798 additions and 15823 deletions
-7
View File
@@ -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:
-8
View File
@@ -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.
-24
View File
@@ -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'
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
-1
View File
@@ -60,7 +60,6 @@ htmlcov/
**/mcp-config.json
**/mcpServers.json
.mcp/
.mcp.json
# AI Coding Assistants - Cursor
.cursorignore
-16
View File
@@ -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:
-4
View File
@@ -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
+1 -1
View File
@@ -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"
+26 -17
View File
@@ -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:
-1
View File
@@ -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,
),
]
+1 -11
View File
@@ -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):
+7 -16
View File
@@ -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]:
-132
View File
@@ -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)
+1 -64
View File
@@ -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
+5 -50
View File
@@ -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()
+10 -157
View File
@@ -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(
-23
View File
@@ -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"],
},
]
}
)
+3 -41
View File
@@ -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
+32 -134
View File
@@ -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
-7
View File
@@ -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,
)
+1 -9
View File
@@ -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
-1
View File
@@ -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"
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

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"
![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png)
3. Click "Add Cloud Provider"
![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png)
4. Select "Container Registry"
![Select Container Registry](/user-guide/img/select-container-registry.png)
5. Enter the container registry URL (e.g., `docker.io/myorg` or `myregistry.io`) and an optional alias, then click "Next"
![Add Container Registry URL](/user-guide/img/add-registry-url.png)
### 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"
![Image Authentication and Filters](/user-guide/img/image-authentication-filters.png)
### Step 3: Verify Connection & Start Scan
7. Review the provider configuration and click "Launch scan" to initiate the scan
![Verify Connection & Start Scan](/user-guide/img/image-verify-connection.png)
## 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".
![Vercel Account Settings](/user-guide/providers/vercel/images/vercel-account-settings.png)
3. In the left sidebar, click "Tokens".
4. Under **Create Token**, enter a descriptive name (e.g., "Prowler Scan").
5. Select the **Scope** — choose the team to be scanned or "Full Account" for all teams.
6. Set an **Expiration** date, or select "No expiration" for continuous scanning.
7. Click **Create**.
![Create Vercel Token](/user-guide/providers/vercel/images/vercel-create-token.png)
8. Copy the token immediately.
<Warning>
Vercel only displays the token once. Copy it immediately and store it securely. If lost, a new token must be created.
</Warning>
### Step 2: Provide the Token to Prowler
Export the token as an environment variable:
```console
export VERCEL_TOKEN="your-api-token-here"
prowler vercel
```
---
## Team Scoping (Optional)
By default, Prowler auto-discovers all teams the authenticated user belongs to and scans each one. To restrict the scan to a specific team, provide the Team ID.
### Locate the Team ID
1. In the Vercel Dashboard, navigate to "Settings" for the target team.
2. Scroll down to the **Team ID** section and copy the value.
![Vercel Team ID](/user-guide/providers/vercel/images/vercel-team-id.png)
### Provide the Team ID to Prowler
Export the Team ID as an environment variable:
```console
export VERCEL_TOKEN="your-api-token-here"
export VERCEL_TEAM="team_yourteamid"
prowler vercel
```
---
## Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `VERCEL_TOKEN` | Yes | Vercel API Bearer Token |
| `VERCEL_TEAM` | No | Team ID or slug to scope the scan to a single team |
---
## Best Practices
- **Create a dedicated token for Prowler** — Avoid reusing tokens shared with other integrations.
- **Use environment variables** — Never hardcode credentials in scripts or commands.
- **Scope tokens to specific teams** — When possible, limit token access to the team being scanned.
- **Set token expiration** — Use time-limited tokens and rotate them regularly.
- **Use least privilege** — Assign the Viewer role to the user creating the token unless write access is explicitly needed.
---
## Troubleshooting
### "Vercel credentials not found" Error
This error occurs when no API Token is provided. Ensure the `VERCEL_TOKEN` environment variable is set:
```console
export VERCEL_TOKEN="your-api-token-here"
```
### "Invalid or expired Vercel API token" Error
- Verify the API Token is correct and has not expired.
- Check that the token has not been revoked in the Vercel Dashboard under "Settings" > "Tokens".
### "Insufficient permissions" Error
- Ensure the user that created the token has at least a **Viewer** role on the target team.
- If scanning a specific team, verify the token scope includes that team.
### "Team not found or not accessible" Error
This error occurs when the provided `VERCEL_TEAM` value does not match an accessible team. Verify the Team ID is correct:
1. Navigate to the team "Settings" in the Vercel Dashboard.
2. Copy the exact **Team ID** value from the settings page.
### "Rate limit exceeded" Error
Vercel applies rate limits to API requests. Prowler automatically retries rate-limited requests up to 3 times with exponential backoff. If this error persists:
- Reduce the number of projects being scanned in a single run using the `--project` argument.
- Wait a few minutes and retry the scan.
@@ -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 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

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
-8
View File
@@ -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()
-10
View File
@@ -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)
---
-21
View File
@@ -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/"
File diff suppressed because it is too large Load Diff
@@ -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"
]
},
-1
View File
@@ -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)
-31
View File
@@ -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"
-5
View File
@@ -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
-44
View File
@@ -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:
"""
+2 -5
View File
@@ -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
-68
View File
@@ -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
-17
View File
@@ -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"
+1 -67
View File
@@ -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:
"""
+1 -16
View File
@@ -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}"
-2
View File
@@ -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:
-8
View File
@@ -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",
-13
View File
@@ -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
-52
View File
@@ -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())

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