mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-07 07:57:11 +00:00
Compare commits
15 Commits
dependabot
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7b915d6d3 | ||
|
|
167bcca67d | ||
|
|
5fff3b920d | ||
|
|
961f9c86da | ||
|
|
0f1da703d1 | ||
|
|
07f3416493 | ||
|
|
0debfba4e8 | ||
|
|
e5fd366ea9 | ||
|
|
d218e87209 | ||
|
|
58d2ba81c4 | ||
|
|
f55839797c | ||
|
|
a40b6dd51b | ||
|
|
a6cba5af58 | ||
|
|
c71abf0c59 | ||
|
|
dccfcf2848 |
33
.github/workflows/check-test-init-files.yml
vendored
Normal file
33
.github/workflows/check-test-init-files.yml
vendored
Normal 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 .
|
||||
4
.github/workflows/ui-e2e-tests-v2.yml
vendored
4
.github/workflows/ui-e2e-tests-v2.yml
vendored
@@ -172,7 +172,7 @@ jobs:
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm and Next.js cache
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
run: pnpm run build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
||||
2
.github/workflows/ui-tests.yml
vendored
2
.github/workflows/ui-tests.yml
vendored
@@ -108,7 +108,7 @@ jobs:
|
||||
|
||||
- name: Setup pnpm and Next.js cache
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -60,6 +60,7 @@ htmlcov/
|
||||
**/mcp-config.json
|
||||
**/mcpServers.json
|
||||
.mcp/
|
||||
.mcp.json
|
||||
|
||||
# AI Coding Assistants - Cursor
|
||||
.cursorignore
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
BIN
docs/user-guide/img/add-registry-url.png
Normal file
BIN
docs/user-guide/img/add-registry-url.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 235 KiB |
BIN
docs/user-guide/img/image-authentication-filters.png
Normal file
BIN
docs/user-guide/img/image-authentication-filters.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
BIN
docs/user-guide/img/image-verify-connection.png
Normal file
BIN
docs/user-guide/img/image-verify-connection.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 200 KiB |
BIN
docs/user-guide/img/select-container-registry.png
Normal file
BIN
docs/user-guide/img/select-container-registry.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
50
docs/user-guide/providers/image/authentication.mdx
Normal file
50
docs/user-guide/providers/image/authentication.mdx
Normal 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>
|
||||
@@ -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"
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
<VersionBadge version="5.19.0" />
|
||||
|
||||
<Note>
|
||||
The Image provider is currently available in Prowler CLI only.
|
||||
</Note>
|
||||
|
||||
### Install Trivy
|
||||
|
||||
Install Trivy using one of the following methods:
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
62
scripts/check_test_init_files.py
Normal file
62
scripts/check_test_init_files.py
Normal 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())
|
||||
68
tests/lib/check_test_init_files_test.py
Normal file
68
tests/lib/check_test_init_files_test.py
Normal 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
Reference in New Issue
Block a user