Compare commits

...

15 Commits

Author SHA1 Message Date
Hugo P.Brito
e7b915d6d3 merge: sync test init cleanup branch with master
- Keep the test init guard workflow in this PR
- Drop unrelated Entra and M365 noise from the diff
- Allow the checks_folder fixtures in the guard script and tests
2026-04-06 15:34:28 +01:00
Hugo P.Brito
167bcca67d chore: remove __init__.py from test directories
- delete test package markers across the repository
- add a guard script and PR workflow to block regressions
- preserve custom check folder fixtures with neutral placeholder files
2026-04-06 13:46:00 +01:00
Adrián Peña
5fff3b920d fix(api): exclude spurious retrieve from Jira docs and add known limitations (#10580) 2026-04-06 14:30:38 +02:00
Pablo Fernandez Guerra (PFE)
961f9c86da feat(ui): Add tenant management (#10491)
Co-authored-by: Pablo Fernandez <pfe@NB0240.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: David <david.copo@gmail.com>
2026-04-06 10:31:30 +02:00
Andoni Alonso
0f1da703d1 docs(image): add Prowler App documentation and authentication guide (#10527)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-04-06 09:59:56 +02:00
Pepe Fagoaga
07f3416493 feat(mcp): Add resource events tool (#10412) 2026-04-06 08:42:04 +02:00
Hugo P.Brito
0debfba4e8 Revert "fix(m365): ignore policies without insider risk conditions"
This reverts commit e5fd366ea9.
2026-03-27 12:33:53 +00:00
Hugo P.Brito
e5fd366ea9 fix(m365): ignore policies without insider risk conditions 2026-03-27 12:32:07 +00:00
Hugo P.Brito
d218e87209 fix(m365): align insider risk status messages with O365 check 2026-03-27 12:26:10 +00:00
Hugo P.Brito
58d2ba81c4 refactor(m365): restore original insider risk check name 2026-03-27 12:18:41 +00:00
Hugo P.Brito
f55839797c fix(m365): set ResourceType to NotDefined 2026-03-27 12:15:00 +00:00
Hugo P.Brito
a40b6dd51b merge: resolve conflicts with master 2026-03-27 11:31:44 +00:00
Hugo P.Brito
a6cba5af58 refactor(m365): rename insider risk check to Purview variant 2026-03-27 10:31:30 +00:00
Hugo P.Brito
c71abf0c59 merge: resolve conflicts with master 2026-03-27 10:29:23 +00:00
HugoPBrito
dccfcf2848 feat(m365): add entra_conditional_access_policy_block_elevated_insider_risk security check
Add new security check entra_conditional_access_policy_block_elevated_insider_risk for m365 provider.
Includes check implementation, metadata, and unit tests.
2026-03-03 13:05:00 +01:00
202 changed files with 1992 additions and 170 deletions

View File

@@ -0,0 +1,33 @@
name: 'Tools: Check Test Init Files'
on:
pull_request:
branches:
- 'master'
- 'v5.*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-test-init-files:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Check for __init__.py files in test directories
run: python3 scripts/check_test_init_files.py .

1
.gitignore vendored
View File

@@ -60,6 +60,7 @@ htmlcov/
**/mcp-config.json
**/mcpServers.json
.mcp/
.mcp.json
# AI Coding Assistants - Cursor
.cursorignore

View File

@@ -6,6 +6,8 @@ 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)

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, Tenant
from api.models import Role, UserRoleRelationship
from api.rbac.permissions import HasPermissions
@@ -113,27 +113,22 @@ class BaseTenantViewset(BaseViewSet):
if request is not None:
request.db_alias = self.db_alias
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
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)
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):
Role.objects.using(MainRouter.admin_db).create(
admin_role = Role.objects.using(MainRouter.admin_db).create(
name="admin",
tenant_id=tenant_id,
manage_users=True,
@@ -144,15 +139,11 @@ class BaseTenantViewset(BaseViewSet):
manage_scans=True,
unlimited_visibility=True,
)
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
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=self.request.user,
role=admin_role,
tenant_id=tenant_id,
)
def initial(self, request, *args, **kwargs):
if request.auth is None:

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,11 +29,17 @@ 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)
.all()
.filter(tenant_id=tenant_id)
)
if not user_roles:
return False
@@ -45,14 +51,17 @@ class HasPermissions(BasePermission):
return True
def get_role(user: User) -> Optional[Role]:
def get_role(user: User, tenant_id: str) -> Role:
"""
Retrieve the first role assigned to the given user.
Retrieve the role assigned to the given user in the specified tenant.
Returns:
The user's first Role instance if the user has any roles, otherwise None.
Raises:
PermissionDenied: If the user has no role in the given tenant.
"""
return user.roles.first()
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
def get_providers(role: Role) -> QuerySet[Provider]:

View File

@@ -215,6 +215,21 @@ 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)

View File

@@ -2,7 +2,7 @@ import json
from unittest.mock import ANY, Mock, patch
import pytest
from conftest import TODAY
from conftest import TEST_PASSWORD, TODAY
from django.urls import reverse
from rest_framework import status
@@ -830,3 +830,66 @@ 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

View File

@@ -516,6 +516,13 @@ 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(
@@ -575,22 +582,66 @@ 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
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
# user2 and user3 are deleted (no other memberships), test_user remains
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):
@@ -694,7 +745,6 @@ 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
):
@@ -807,6 +857,30 @@ 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
):
@@ -8168,6 +8242,8 @@ 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(

View File

@@ -949,7 +949,12 @@ class UserViewSet(BaseUserViewset):
def get_serializer_context(self):
context = super().get_serializer_context()
if self.request.user.is_authenticated:
context["role"] = get_role(self.request.user)
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
return context
@action(detail=False, methods=["get"], url_name="me")
@@ -1231,28 +1236,44 @@ class TenantViewSet(BaseTenantViewset):
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
tenant = serializer.save()
Membership.objects.create(
tenant = Tenant.objects.using(MainRouter.admin_db).create(
**serializer.validated_data
)
Membership.objects.using(MainRouter.admin_db).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):
# 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)
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.")
with transaction.atomic():
# Delete memberships
# 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
Membership.objects.using(MainRouter.admin_db).filter(
tenant_id=tenant_id
).delete()
# Delete users without memberships
User.objects.using(MainRouter.admin_db).filter(
membership__isnull=True
).delete()
# Delete tenant in batches
# 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_tenant_task.apply_async(kwargs={"tenant_id": tenant_id})
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -1317,8 +1338,12 @@ class TenantMembersViewSet(BaseTenantViewset):
http_method_names = ["get", "delete"]
serializer_class = MembershipSerializer
queryset = Membership.objects.none()
# RBAC required permissions
required_permissions = [Permissions.MANAGE_ACCOUNT]
# 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 = []
def get_queryset(self):
tenant = self.get_tenant()
@@ -1331,8 +1356,10 @@ class TenantMembersViewSet(BaseTenantViewset):
def get_tenant(self):
tenant_id = self.kwargs.get("tenant_pk")
tenant = get_object_or_404(Tenant, id=tenant_id)
return tenant
return get_object_or_404(
Tenant.objects.filter(membership__user=self.request.user),
id=tenant_id,
)
def get_requesting_membership(self, tenant):
try:
@@ -1419,7 +1446,7 @@ class ProviderGroupViewSet(BaseRLSViewSet):
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
user_roles = get_role(self.request.user)
user_roles = get_role(self.request.user, self.request.tenant_id)
# Check if any of the user's roles have UNLIMITED_VISIBILITY
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all provider groups
@@ -1588,7 +1615,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
def get_queryset(self):
user_roles = get_role(self.request.user)
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all providers
queryset = Provider.objects.filter(tenant_id=self.request.tenant_id)
@@ -1843,7 +1870,7 @@ class ScanViewSet(BaseRLSViewSet):
self.required_permissions = [Permissions.MANAGE_SCANS]
def get_queryset(self):
user_roles = get_role(self.request.user)
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all scans
queryset = Scan.objects.filter(tenant_id=self.request.tenant_id)
@@ -2494,7 +2521,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
return super().get_serializer_class()
def get_queryset(self):
user_roles = get_role(self.request.user)
user_roles = get_role(self.request.user, self.request.tenant_id)
base_queryset = AttackPathsScan.objects.filter(tenant_id=self.request.tenant_id)
if user_roles.unlimited_visibility:
@@ -2831,7 +2858,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
required_permissions = []
def get_queryset(self):
user_roles = get_role(self.request.user)
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all scans
queryset = Resource.all_objects.filter(tenant_id=self.request.tenant_id)
@@ -3453,7 +3480,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
def get_queryset(self):
tenant_id = self.request.tenant_id
user_roles = get_role(self.request.user)
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all findings
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
@@ -4056,9 +4083,9 @@ class RoleViewSet(BaseRLSViewSet):
)
)
def partial_update(self, request, *args, **kwargs):
user_role = get_role(request.user)
user_role = get_role(request.user, request.tenant_id)
# If the user is the owner of the role, the manage_account field is not editable
if user_role and kwargs["pk"] == str(user_role.id):
if kwargs["pk"] == str(user_role.id):
request.data["manage_account"] = str(user_role.manage_account).lower()
return super().partial_update(request, *args, **kwargs)
@@ -4314,7 +4341,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
required_permissions = []
def get_queryset(self):
role = get_role(self.request.user)
role = get_role(self.request.user, self.request.tenant_id)
unlimited_visibility = getattr(
role, Permissions.UNLIMITED_VISIBILITY.value, False
)
@@ -4356,7 +4383,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)
role = get_role(self.request.user, self.request.tenant_id)
unlimited_visibility = getattr(
role, Permissions.UNLIMITED_VISIBILITY.value, False
)
@@ -4898,7 +4925,7 @@ class OverviewViewSet(BaseRLSViewSet):
required_permissions = []
def get_queryset(self):
role = get_role(self.request.user)
role = get_role(self.request.user, self.request.tenant_id)
providers = get_providers(role)
if not role.unlimited_visibility:
@@ -6071,7 +6098,7 @@ class IntegrationViewSet(BaseRLSViewSet):
allowed_providers = None
def get_queryset(self):
user_roles = get_role(self.request.user)
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all integrations
queryset = Integration.objects.filter(tenant_id=self.request.tenant_id)
@@ -6126,7 +6153,15 @@ class IntegrationViewSet(BaseRLSViewSet):
tags=["Integration"],
summary="Send findings to a Jira integration",
description="Send a set of filtered findings to the given integration. At least one finding filter must be "
"provided.",
"provided.\n\n"
"## Known Limitations\n\n"
"### Issue Types with Required Custom Fields\n\n"
"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.\n\n'
"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.',
responses={202: OpenApiResponse(response=TaskSerializer)},
filters=True,
)
@@ -6148,6 +6183,10 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
def list(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
@extend_schema(exclude=True)
def retrieve(self, request, *args, **kwargs):
raise MethodNotAllowed(method="GET")
def get_serializer_class(self):
if self.action == "issue_types":
return IntegrationJiraIssueTypesSerializer
@@ -6160,7 +6199,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
def get_queryset(self):
tenant_id = self.request.tenant_id
user_roles = get_role(self.request.user)
user_roles = get_role(self.request.user, self.request.tenant_id)
if user_roles.unlimited_visibility:
# User has unlimited visibility, return all findings
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
@@ -6902,7 +6941,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)
role = get_role(self.request.user, self.request.tenant_id)
queryset = FindingGroupDailySummary.objects.filter(tenant_id=tenant_id)
if not role.unlimited_visibility:
@@ -6912,7 +6951,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)
role = get_role(self.request.user, self.request.tenant_id)
providers = get_providers(role)
tenant_id = self.request.tenant_id

View File

View File

@@ -274,7 +274,8 @@
{
"group": "Image",
"pages": [
"user-guide/providers/image/getting-started-image"
"user-guide/providers/image/getting-started-image",
"user-guide/providers/image/authentication"
]
},
{

View File

@@ -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 | 27 tools | Yes |
| Prowler Cloud/App | 29 tools | Yes |
## Tool Naming Convention
@@ -60,6 +60,7 @@ 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
@@ -87,6 +88,7 @@ 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -0,0 +1,50 @@
---
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>

View File

@@ -9,18 +9,69 @@ 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:
@@ -55,7 +106,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, only vulnerability and secret scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below.
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.
### Scan Container Images
@@ -112,7 +163,7 @@ Valid examples:
#### Specify Scanners
To select which scanners Trivy runs, use the `--scanners` option. By default, Prowler enables `vuln` and `secret` scanners:
To select which scanners Trivy runs, use the `--scanners` option:
```bash
# Vulnerability scanning only
@@ -272,7 +323,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 automatically runs `docker login`, pulls the image, and performs a `docker logout` after the scan completes:
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"
@@ -281,7 +332,7 @@ export REGISTRY_PASSWORD="mypassword"
prowler image -I myregistry.io/myapp:v1.0
```
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.
Both variables must be set for this method to activate.
#### 2. Token-Based Authentication
@@ -306,7 +357,7 @@ prowler image -I myregistry.io/myapp:v1.0
```
<Note>
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.
Credentials provided via environment variables are only passed to the Trivy subprocess and are not persisted beyond the scan.
</Note>
### Troubleshooting Common Scan Errors

View File

@@ -2,6 +2,14 @@
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

View File

@@ -135,3 +135,48 @@ 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),
)

View File

@@ -8,6 +8,7 @@ from typing import Any
from prowler_mcp_server.prowler_app.models.resources import (
DetailedResource,
ResourceEventsResponse,
ResourcesListResponse,
ResourcesMetadataResponse,
)
@@ -342,3 +343,62 @@ 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()

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Fail when __init__.py files are present inside test directories."""
from __future__ import annotations
import sys
from argparse import ArgumentParser
from pathlib import Path
EXCLUDED_TEST_INIT_ROOTS = {
Path("tests/lib/check/fixtures/checks_folder"),
}
def is_test_init_file(path: Path) -> bool:
"""Return True when the file is a test __init__.py."""
return path.name == "__init__.py" and "tests" in path.parts
def is_excluded_test_init_file(path: Path, root: Path) -> bool:
"""Return True when the file belongs to an allowed fixture directory."""
relative_path = path.relative_to(root)
return any(
relative_path.is_relative_to(excluded) for excluded in EXCLUDED_TEST_INIT_ROOTS
)
def find_test_init_files(root: Path) -> list[Path]:
"""Return sorted __init__.py files found under test directories."""
return sorted(
path
for path in root.rglob("__init__.py")
if is_test_init_file(path) and not is_excluded_test_init_file(path, root)
)
def main(argv: list[str] | None = None) -> int:
parser = ArgumentParser(description=__doc__)
parser.add_argument(
"root",
nargs="?",
default=".",
help="Repository root to scan. Defaults to the current directory.",
)
args = parser.parse_args(argv)
root = Path(args.root).resolve()
matches = find_test_init_files(root)
if not matches:
print("No __init__.py files found in test directories.")
return 0
print("Remove __init__.py files from test directories:")
for path in matches:
print(path.relative_to(root))
return 1
if __name__ == "__main__":
sys.exit(main())

View File

View File

@@ -0,0 +1,68 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
SCRIPT_PATH = (
Path(__file__).resolve().parents[2] / "scripts" / "check_test_init_files.py"
)
def load_guard_module():
spec = spec_from_file_location("check_test_init_files", SCRIPT_PATH)
assert spec is not None
assert spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_find_test_init_files_detects_only_test_directories(tmp_path):
guard = load_guard_module()
(tmp_path / "tests" / "providers" / "aws").mkdir(parents=True)
(tmp_path / "tests" / "providers" / "aws" / "__init__.py").write_text("")
(tmp_path / "api" / "tests" / "performance").mkdir(parents=True)
(tmp_path / "api" / "tests" / "performance" / "__init__.py").write_text("")
(tmp_path / "prowler" / "providers" / "aws").mkdir(parents=True)
(tmp_path / "prowler" / "providers" / "aws" / "__init__.py").write_text("")
(
tmp_path / "tests" / "lib" / "check" / "fixtures" / "checks_folder" / "check11"
).mkdir(parents=True)
(
tmp_path
/ "tests"
/ "lib"
/ "check"
/ "fixtures"
/ "checks_folder"
/ "check11"
/ "__init__.py"
).write_text("")
matches = guard.find_test_init_files(tmp_path)
assert [path.relative_to(tmp_path) for path in matches] == [
Path("api/tests/performance/__init__.py"),
Path("tests/providers/aws/__init__.py"),
]
def test_main_returns_error_when_test_init_files_exist(tmp_path, capsys):
guard = load_guard_module()
(tmp_path / "tests" / "config").mkdir(parents=True)
(tmp_path / "tests" / "config" / "__init__.py").write_text("")
assert guard.main([str(tmp_path)]) == 1
captured = capsys.readouterr()
assert "Remove __init__.py files from test directories" in captured.out
assert "tests/config/__init__.py" in captured.out
def test_repository_has_no_test_init_files():
guard = load_guard_module()
repo_root = Path(__file__).resolve().parents[2]
assert guard.find_test_init_files(repo_root) == []

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