feat(aws): CloudTrail timeline for findings (#9101)

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
This commit is contained in:
authored and GitHub committed 2026-01-27 13:00:46 +01:00
1 parent 255ce0e866
commit 9e7ecb39fa
17 files changed
+2370 -28

No files matched your search

+102
View File
@@ -107,3 +107,105 @@ class ConflictException(APIException):
error_detail["source"] = {"pointer": pointer}
super().__init__(detail=[error_detail])
# Upstream Provider Errors (for external API calls like CloudTrail)
# These indicate issues with the provider, not with the user's API authentication
class UpstreamAuthenticationError(APIException):
"""Provider credentials are invalid or expired (502 Bad Gateway).
Used when AWS/Azure/GCP credentials fail to authenticate with the upstream
provider. This is NOT the user's API authentication failing.
"""
status_code = status.HTTP_502_BAD_GATEWAY
default_detail = (
"Provider credentials are invalid or expired. Please reconnect the provider."
)
default_code = "upstream_auth_failed"
def __init__(self, detail=None):
super().__init__(
detail=[
{
"detail": detail or self.default_detail,
"status": str(self.status_code),
"code": self.default_code,
}
]
)
class UpstreamAccessDeniedError(APIException):
"""Provider credentials lack required permissions (502 Bad Gateway).
Used when credentials are valid but don't have the IAM permissions
needed for the requested operation (e.g., cloudtrail:LookupEvents).
This is 502 (not 403) because it's an upstream/gateway error - the USER
authenticated fine, but the PROVIDER's credentials are misconfigured.
"""
status_code = status.HTTP_502_BAD_GATEWAY
default_detail = (
"Access denied. The provider credentials do not have the required permissions."
)
default_code = "upstream_access_denied"
def __init__(self, detail=None):
super().__init__(
detail=[
{
"detail": detail or self.default_detail,
"status": str(self.status_code),
"code": self.default_code,
}
]
)
class UpstreamServiceUnavailableError(APIException):
"""Provider service is unavailable (503 Service Unavailable).
Used when the upstream provider API returns an error or is unreachable.
"""
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
default_detail = "Unable to communicate with the provider. Please try again later."
default_code = "service_unavailable"
def __init__(self, detail=None):
super().__init__(
detail=[
{
"detail": detail or self.default_detail,
"status": str(self.status_code),
"code": self.default_code,
}
]
)
class UpstreamInternalError(APIException):
"""Unexpected error communicating with provider (500 Internal Server Error).
Used as a catch-all for unexpected errors during provider communication.
"""
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
default_detail = (
"An unexpected error occurred while communicating with the provider."
)
default_code = "internal_error"
def __init__(self, detail=None):
super().__init__(
detail=[
{
"detail": detail or self.default_detail,
"status": str(self.status_code),
"code": self.default_code,
}
]
)
+124
View File
@@ -8502,6 +8502,64 @@ paths:
schema:
$ref: '#/components/schemas/ResourceResponse'
description: ''
/api/v1/resources/{id}/events:
get:
operationId: resources_events_list
description: |-
Retrieve events showing modification history for a resource. Returns who modified the resource and when. Currently only available for AWS resources.
**Note:** Some events may not appear due to CloudTrail indexing limitations. Not all AWS API calls record the resource identifier in a searchable format.
summary: Get events for a resource
parameters:
- in: path
name: id
schema:
type: string
format: uuid
description: A UUID string identifying this resource.
required: true
- in: query
name: include_read_events
schema:
type: boolean
description: 'Include read-only events (Describe*, Get*, List*, etc.). Default:
false. Set to true to include all events.'
- in: query
name: lookback_days
schema:
type: integer
description: 'Number of days to look back (default: 90, min: 1, max: 90).'
- name: page[number]
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- in: query
name: page[size]
schema:
type: integer
description: 'Maximum number of events to return (default: 50, min: 1, max:
50).'
tags:
- Resource
security:
- JWT or API Key: []
responses:
'200':
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/PaginatedResourceEventList'
description: ''
'400':
description: Invalid provider or parameters
'500':
description: Unexpected error retrieving events
'502':
description: Provider credentials invalid, expired, or lack required permissions
'503':
description: Provider service unavailable
/api/v1/resources/latest:
get:
operationId: resources_latest_retrieve
@@ -15885,6 +15943,15 @@ components:
$ref: '#/components/schemas/ProviderSecret'
required:
- data
PaginatedResourceEventList:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/ResourceEvent'
required:
- data
PaginatedResourceGroupOverviewList:
type: object
properties:
@@ -19967,6 +20034,63 @@ components:
readOnly: true
required:
- provider
ResourceEvent:
type: object
required:
- type
- id
additionalProperties: false
properties:
type:
type: string
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
member is used to describe resource objects that share common attributes
and relationships.
enum:
- resource-events
id: {}
attributes:
type: object
properties:
id:
type: string
event_time:
type: string
format: date-time
event_name:
type: string
event_source:
type: string
actor:
type: string
actor_uid:
type: string
nullable: true
actor_type:
type: string
nullable: true
source_ip_address:
type: string
nullable: true
user_agent:
type: string
nullable: true
request_data:
nullable: true
response_data:
nullable: true
error_code:
type: string
nullable: true
error_message:
type: string
nullable: true
required:
- id
- event_time
- event_name
- event_source
- actor
ResourceGroupOverview:
type: object
required:
+655
View File
@@ -4356,6 +4356,661 @@ class TestResourceViewSet:
assert attributes["types"] == [latest_scan_resource.type]
assert "groups" in attributes
# Events endpoint tests
def test_events_non_aws_provider(self, authenticated_client, providers_fixture):
"""Test events endpoint rejects non-AWS providers."""
from api.models import Resource
azure_provider = providers_fixture[4] # Azure provider from fixture
resource = Resource.objects.create(
uid="test-resource-id",
name="Test Resource",
type="test-type",
region="us-east-1",
service="test-service",
provider=azure_provider,
tenant_id=azure_provider.tenant_id,
)
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id})
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Verify JSON:API error structure
error = response.json()["errors"][0]
assert error["code"] == "invalid_provider"
assert error["status"] == "400" # Must be string per JSON:API spec
assert error["source"]["pointer"] == "/data/attributes/provider"
assert "AWS" in error["detail"]
@pytest.mark.parametrize(
"lookback_days,expected_status,expected_code,expected_detail_contains",
[
("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"),
("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
("91", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
("-5", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 90"),
],
)
def test_events_invalid_lookback_days(
self,
authenticated_client,
providers_fixture,
lookback_days,
expected_status,
expected_code,
expected_detail_contains,
):
"""Test events endpoint validates lookback_days with JSON:API compliant errors."""
from api.models import Resource
aws_provider = providers_fixture[0] # AWS provider from fixture
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
name="Test Instance",
type="instance",
region="us-east-1",
service="ec2",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id}),
{"lookback_days": lookback_days},
)
assert response.status_code == expected_status
# Verify JSON:API error structure
error = response.json()["errors"][0]
assert error["code"] == expected_code
assert error["status"] == "400" # Must be string per JSON:API spec
assert error["source"]["parameter"] == "lookback_days"
assert expected_detail_contains in error["detail"]
@pytest.mark.parametrize(
"page_size,expected_status,expected_code,expected_detail_contains",
[
("abc", status.HTTP_400_BAD_REQUEST, "invalid", "valid integer"),
("0", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
("51", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
("-1", status.HTTP_400_BAD_REQUEST, "out_of_range", "between 1 and 50"),
],
)
def test_events_invalid_page_size(
self,
authenticated_client,
providers_fixture,
page_size,
expected_status,
expected_code,
expected_detail_contains,
):
"""Test events endpoint validates page[size] with JSON:API compliant errors."""
from api.models import Resource
aws_provider = providers_fixture[0] # AWS provider from fixture
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-pagesize-test",
name="Test Instance",
type="instance",
region="us-east-1",
service="ec2",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id}),
{"page[size]": page_size},
)
assert response.status_code == expected_status
# Verify JSON:API error structure
error = response.json()["errors"][0]
assert error["code"] == expected_code
assert error["status"] == "400" # Must be string per JSON:API spec
assert error["source"]["parameter"] == "page[size]"
assert expected_detail_contains in error["detail"]
@pytest.mark.parametrize(
"invalid_params,expected_invalid_param",
[
({"filter[service]": "ec2"}, "filter[service]"),
({"filter[region]": "us-east-1"}, "filter[region]"),
({"sort": "-name"}, "sort"),
({"unknown_param": "value"}, "unknown_param"),
({"filter[servic]": "ec2"}, "filter[servic]"), # Typo in filter name
],
)
def test_events_invalid_query_parameter(
self,
authenticated_client,
providers_fixture,
invalid_params,
expected_invalid_param,
):
"""Test events endpoint rejects unknown query parameters with JSON:API compliant errors."""
from api.models import Resource
aws_provider = providers_fixture[0] # AWS provider from fixture
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
name="Test Instance",
type="instance",
region="us-east-1",
service="ec2",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id}),
invalid_params,
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Verify JSON:API error structure
errors = response.json()["errors"]
assert len(errors) >= 1
# Find the error for our expected invalid param
error = next(
(e for e in errors if e["source"]["parameter"] == expected_invalid_param),
None,
)
assert (
error is not None
), f"Expected error for parameter '{expected_invalid_param}'"
assert error["code"] == "invalid"
assert error["status"] == "400" # Must be string per JSON:API spec
assert expected_invalid_param in error["detail"]
def test_events_multiple_invalid_query_parameters(
self,
authenticated_client,
providers_fixture,
):
"""Test events endpoint returns error for first unknown parameter."""
from api.models import Resource
aws_provider = providers_fixture[0]
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test",
name="Test Instance",
type="instance",
region="us-east-1",
service="ec2",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
# Send multiple invalid parameters - only first one triggers error
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id}),
{"filter[service]": "ec2", "sort": "-name", "unknown": "value"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
# Should have one error for the first invalid parameter encountered
errors = response.json()["errors"]
assert len(errors) == 1
assert errors[0]["code"] == "invalid"
assert errors[0]["status"] == "400"
assert errors[0]["source"]["parameter"] in {
"filter[service]",
"sort",
"unknown",
}
@patch("api.v1.views.initialize_prowler_provider")
@patch("api.v1.views.CloudTrailTimeline")
def test_events_success(
self,
mock_cloudtrail_timeline,
mock_initialize_provider,
authenticated_client,
providers_fixture,
):
"""Test successful events retrieval."""
from api.models import Resource
aws_provider = providers_fixture[0] # AWS provider from fixture
# Create test resource
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-test123",
name="Test EC2 Instance",
type="instance",
region="us-east-1",
service="ec2",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
# Mock provider session
mock_session = Mock()
mock_provider = Mock()
mock_provider._session.current_session = mock_session
mock_initialize_provider.return_value = mock_provider
# Mock CloudTrail timeline response - events need event_id for serializer
mock_timeline_instance = Mock()
mock_events = [
{
"event_id": "event-1-id",
"event_time": "2024-01-15T10:30:00Z",
"event_name": "RunInstances",
"event_source": "ec2.amazonaws.com",
"actor": "admin@example.com",
"actor_type": "IAMUser",
"source_ip_address": "203.0.113.1",
"user_agent": "aws-cli/2.0.0",
},
{
"event_id": "event-2-id",
"event_time": "2024-01-16T14:20:00Z",
"event_name": "StopInstances",
"event_source": "ec2.amazonaws.com",
"actor": "operator@example.com",
"actor_type": "IAMUser",
},
]
mock_timeline_instance.get_resource_timeline.return_value = mock_events
mock_cloudtrail_timeline.return_value = mock_timeline_instance
# Make request with lookback_days parameter
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id}),
{"lookback_days": "30"},
)
# Assertions - response is wrapped by JSON:API renderer
assert response.status_code == status.HTTP_200_OK
response_data = response.json()
events = response_data["data"]
assert len(events) == 2
# Verify JSON:API structure: type and id are present
assert events[0]["type"] == "resource-events"
assert events[0]["id"] == "event-1-id"
assert events[1]["type"] == "resource-events"
assert events[1]["id"] == "event-2-id"
# Verify attributes
assert events[0]["attributes"]["event_name"] == "RunInstances"
assert events[0]["attributes"]["actor"] == "admin@example.com"
assert events[1]["attributes"]["event_name"] == "StopInstances"
# Verify CloudTrail was called with correct parameters
mock_cloudtrail_timeline.assert_called_once_with(
session=mock_session,
lookback_days=30,
max_results=50, # Default page size
write_events_only=True, # Default: exclude read events
)
mock_timeline_instance.get_resource_timeline.assert_called_once_with(
region=resource.region,
resource_uid=resource.uid,
)
@patch("api.v1.views.initialize_prowler_provider")
@patch("api.v1.views.CloudTrailTimeline")
def test_events_default_lookback_days(
self,
mock_cloudtrail_timeline,
mock_initialize_provider,
authenticated_client,
providers_fixture,
):
"""Test events uses default lookback_days (90) when not provided."""
from api.models import Resource
aws_provider = providers_fixture[0] # AWS provider from fixture
resource = Resource.objects.create(
uid="arn:aws:s3:::test-bucket",
name="Test Bucket",
type="bucket",
region="us-east-1",
service="s3",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
# Mock provider session
mock_session = Mock()
mock_provider = Mock()
mock_provider._session.current_session = mock_session
mock_initialize_provider.return_value = mock_provider
# Mock CloudTrail timeline response
mock_timeline_instance = Mock()
mock_timeline_instance.get_resource_timeline.return_value = []
mock_cloudtrail_timeline.return_value = mock_timeline_instance
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id})
)
assert response.status_code == status.HTTP_200_OK
# Verify default lookback_days (90) was used
mock_cloudtrail_timeline.assert_called_once_with(
session=mock_session,
lookback_days=90, # Default
max_results=50,
write_events_only=True,
)
@patch("api.v1.views.initialize_prowler_provider")
def test_events_no_credentials_error(
self, mock_initialize_provider, authenticated_client, providers_fixture
):
"""Test events handles missing credentials errors."""
from api.models import Resource
aws_provider = providers_fixture[0] # AWS provider from fixture
resource = Resource.objects.create(
uid="arn:aws:rds:us-west-2:123456789012:db:test-db",
name="Test Database",
type="db-instance",
region="us-west-2",
service="rds",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
mock_initialize_provider.side_effect = NoCredentialsError()
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id})
)
# 502 because this is an upstream auth failure, not API auth failure
assert response.status_code == status.HTTP_502_BAD_GATEWAY
# Verify JSON:API error structure
error = response.json()["errors"][0]
assert error["code"] == "upstream_auth_failed"
assert error["status"] == "502" # Must be string per JSON:API spec
assert "detail" in error
@patch("api.v1.views.initialize_prowler_provider")
@patch("api.v1.views.CloudTrailTimeline")
def test_events_access_denied_error(
self,
mock_cloudtrail_timeline,
mock_initialize_provider,
authenticated_client,
providers_fixture,
):
"""Test events handles AccessDenied errors from AWS."""
from api.models import Resource
aws_provider = providers_fixture[0] # AWS provider from fixture
resource = Resource.objects.create(
uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func",
name="Test Function",
type="function",
region="eu-west-1",
service="lambda",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
# Mock provider
mock_session = Mock()
mock_provider = Mock()
mock_provider._session.current_session = mock_session
mock_initialize_provider.return_value = mock_provider
# Mock ClientError with AccessDenied
mock_timeline_instance = Mock()
mock_timeline_instance.get_resource_timeline.side_effect = ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
"LookupEvents",
)
mock_cloudtrail_timeline.return_value = mock_timeline_instance
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id})
)
# AccessDenied returns 502 (upstream error, not user's fault)
assert response.status_code == status.HTTP_502_BAD_GATEWAY
# Verify JSON:API error structure
error = response.json()["errors"][0]
assert error["code"] == "upstream_access_denied"
assert error["status"] == "502" # Must be string per JSON:API spec
assert "detail" in error
@patch("api.v1.views.initialize_prowler_provider")
@patch("api.v1.views.CloudTrailTimeline")
def test_events_service_unavailable_error(
self,
mock_cloudtrail_timeline,
mock_initialize_provider,
authenticated_client,
providers_fixture,
):
"""Test events handles generic AWS API errors as 503."""
from api.models import Resource
aws_provider = providers_fixture[0] # AWS provider from fixture
resource = Resource.objects.create(
uid="arn:aws:lambda:eu-west-1:123456789012:function:test-func2",
name="Test Function 2",
type="function",
region="eu-west-1",
service="lambda",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
# Mock provider
mock_session = Mock()
mock_provider = Mock()
mock_provider._session.current_session = mock_session
mock_initialize_provider.return_value = mock_provider
# Mock ClientError with non-AccessDenied error
mock_timeline_instance = Mock()
mock_timeline_instance.get_resource_timeline.side_effect = ClientError(
{"Error": {"Code": "ServiceUnavailable", "Message": "Service unavailable"}},
"LookupEvents",
)
mock_cloudtrail_timeline.return_value = mock_timeline_instance
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id})
)
# Non-AccessDenied errors return 503
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
# Verify JSON:API error structure
error = response.json()["errors"][0]
assert error["code"] == "service_unavailable"
assert error["status"] == "503" # Must be string per JSON:API spec
assert "detail" in error
def test_events_unauthenticated_returns_401(self, providers_fixture):
"""Test events endpoint returns 401 when no credentials are provided.
This ensures the endpoint follows API conventions where missing authentication
returns 401 Unauthorized, not 404 Not Found.
"""
from rest_framework.test import APIClient
from api.models import Resource
aws_provider = providers_fixture[0] # AWS provider from fixture
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-unauth-test",
name="Test Instance",
type="instance",
region="us-east-1",
service="ec2",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
# Use unauthenticated client (no JWT token)
unauthenticated_client = APIClient()
response = unauthenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id})
)
# Must return 401 Unauthorized, not 404 Not Found
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
f"Expected 401 Unauthorized but got {response.status_code}. "
"Unauthenticated requests should return 401, not 404."
)
def test_events_cross_tenant_returns_404(
self, authenticated_client, tenants_fixture
):
"""Test events endpoint returns 404 for resources in other tenants (RLS).
Users cannot access resources belonging to other tenants due to
Row-Level Security. The resource should appear to not exist.
"""
from api.models import Provider, Resource
# tenant3 (tenants_fixture[2]) has no membership for the test user
isolated_tenant = tenants_fixture[2]
# Create provider in the isolated tenant
other_tenant_provider = Provider.objects.create(
provider="aws",
uid="999999999999",
alias="other_tenant_aws",
tenant_id=isolated_tenant.id,
)
# Create resource in the OTHER tenant (not the authenticated user's tenant)
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:999999999999:instance/i-other-tenant",
name="Other Tenant Resource",
type="instance",
region="us-east-1",
service="ec2",
provider=other_tenant_provider,
tenant_id=isolated_tenant.id,
)
response = authenticated_client.get(
reverse("resource-events", kwargs={"pk": resource.id})
)
# RLS hides resources from other tenants - should appear as not found
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_events_expired_token_returns_401(self, providers_fixture, tenants_fixture):
"""Test events endpoint returns 401 when JWT token is expired.
Expired tokens should return 401 Unauthorized, not 404 Not Found.
This ensures authentication errors are properly distinguished from
resource not found errors.
"""
from rest_framework.test import APIClient
from api.models import Resource
aws_provider = providers_fixture[0]
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-expired-test",
name="Test Instance",
type="instance",
region="us-east-1",
service="ec2",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
# Create an expired JWT token
tenant = tenants_fixture[0]
expired_payload = {
"token_type": "access",
"exp": datetime.now(timezone.utc)
- timedelta(hours=1), # Expired 1 hour ago
"iat": datetime.now(timezone.utc) - timedelta(hours=2),
"jti": str(uuid4()),
"user_id": str(uuid4()),
"tenant_id": str(tenant.id),
}
expired_token = jwt.encode(
expired_payload, settings.SECRET_KEY, algorithm="HS256"
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")
response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
# Must return 401 Unauthorized, not 404 Not Found
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
f"Expected 401 Unauthorized but got {response.status_code}. "
"Expired tokens should return 401, not 404."
)
def test_events_invalid_token_returns_401(self, providers_fixture):
"""Test events endpoint returns 401 when JWT token is completely invalid.
Malformed or invalid tokens should return 401 Unauthorized, not 404 Not Found.
"""
from rest_framework.test import APIClient
from api.models import Resource
aws_provider = providers_fixture[0]
resource = Resource.objects.create(
uid="arn:aws:ec2:us-east-1:123456789012:instance/i-invalid-test",
name="Test Instance",
type="instance",
region="us-east-1",
service="ec2",
provider=aws_provider,
tenant_id=aws_provider.tenant_id,
)
client = APIClient()
# Test with completely malformed token
client.credentials(HTTP_AUTHORIZATION="Bearer not.a.valid.jwt.token")
response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
assert (
response.status_code == status.HTTP_401_UNAUTHORIZED
), f"Expected 401 for malformed token but got {response.status_code}"
# Test with empty bearer token
client.credentials(HTTP_AUTHORIZATION="Bearer ")
response = client.get(reverse("resource-events", kwargs={"pk": resource.id}))
assert (
response.status_code == status.HTTP_401_UNAUTHORIZED
), f"Expected 401 for empty bearer token but got {response.status_code}"
@pytest.mark.django_db
class TestFindingViewSet:
+28
View File
@@ -3975,3 +3975,31 @@ class ThreatScoreSnapshotSerializer(RLSSerializer):
if getattr(obj, "_aggregated", False):
return "n/a"
return str(obj.id)
# Resource Events Serializers
class ResourceEventSerializer(BaseSerializerV1):
"""Serializer for resource events (CloudTrail modification history).
NOTE: drf-spectacular auto-generates fields[resource-events] sparse fieldsets
parameter in the OpenAPI schema. This endpoint does not support sparse fieldsets.
"""
id = serializers.CharField(source="event_id")
event_time = serializers.DateTimeField()
event_name = serializers.CharField()
event_source = serializers.CharField()
actor = serializers.CharField()
actor_uid = serializers.CharField(allow_null=True, required=False)
actor_type = serializers.CharField(allow_null=True, required=False)
source_ip_address = serializers.CharField(allow_null=True, required=False)
user_agent = serializers.CharField(allow_null=True, required=False)
request_data = serializers.JSONField(allow_null=True, required=False)
response_data = serializers.JSONField(allow_null=True, required=False)
error_code = serializers.CharField(allow_null=True, required=False)
error_message = serializers.CharField(allow_null=True, required=False)
class Meta:
resource_name = "resource-events"
+261 -27
View File
@@ -3,7 +3,6 @@ import glob
import json
import logging
import os
from collections import defaultdict
from copy import deepcopy
from datetime import datetime, timedelta, timezone
@@ -11,7 +10,6 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from urllib.parse import urljoin
import sentry_sdk
from allauth.socialaccount.models import SocialAccount, SocialApp
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
@@ -75,13 +73,27 @@ from rest_framework.generics import GenericAPIView, get_object_or_404
from rest_framework.permissions import SAFE_METHODS
from rest_framework_json_api.views import RelationshipView, Response
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from api.attack_paths import (
database as graph_database,
get_queries_for_provider,
get_query_by_id,
views_helpers as attack_paths_views_helpers,
from tasks.beat import schedule_provider_scan
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
from tasks.jobs.export import get_s3_client
from tasks.tasks import (
backfill_compliance_summaries_task,
backfill_scan_resource_summaries_task,
check_integration_connection_task,
check_lighthouse_connection_task,
check_lighthouse_provider_connection_task,
check_provider_connection_task,
delete_provider_task,
delete_tenant_task,
jira_integration_task,
mute_historical_findings_task,
perform_scan_task,
refresh_lighthouse_provider_models_task,
)
from api.attack_paths import database as graph_database
from api.attack_paths import get_queries_for_provider, get_query_by_id
from api.attack_paths import views_helpers as attack_paths_views_helpers
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
from api.compliance import (
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
@@ -89,7 +101,13 @@ from api.compliance import (
)
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import TaskFailedException
from api.exceptions import (
TaskFailedException,
UpstreamAccessDeniedError,
UpstreamAuthenticationError,
UpstreamInternalError,
UpstreamServiceUnavailableError,
)
from api.filters import (
AttackPathsScanFilter,
AttackSurfaceOverviewFilter,
@@ -173,6 +191,7 @@ from api.rls import Tenant
from api.utils import (
CustomOAuth2Client,
get_findings_metadata_no_aggregations,
initialize_prowler_provider,
validate_invitation,
)
from api.uuid_utils import datetime_to_uuid7, uuid7_start
@@ -234,6 +253,7 @@ from api.v1.serializers import (
ProviderSecretUpdateSerializer,
ProviderSerializer,
ProviderUpdateSerializer,
ResourceEventSerializer,
ResourceGroupOverviewSerializer,
ResourceMetadataSerializer,
ResourceSerializer,
@@ -264,22 +284,9 @@ from api.v1.serializers import (
UserSerializer,
UserUpdateSerializer,
)
from tasks.beat import schedule_provider_scan
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
from tasks.jobs.export import get_s3_client
from tasks.tasks import (
backfill_compliance_summaries_task,
backfill_scan_resource_summaries_task,
check_integration_connection_task,
check_lighthouse_connection_task,
check_lighthouse_provider_connection_task,
check_provider_connection_task,
delete_provider_task,
delete_tenant_task,
jira_integration_task,
mute_historical_findings_task,
perform_scan_task,
refresh_lighthouse_provider_models_task,
from prowler.providers.aws.exceptions.exceptions import AWSCredentialsError
from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import (
CloudTrailTimeline,
)
logger = logging.getLogger(BackendLogger.API)
@@ -2504,6 +2511,20 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
http_method_names = ["get"]
filterset_class = ResourceFilter
ordering = ["-failed_findings_count", "-updated_at"]
# Events endpoint constants (currently AWS-only, limited to 90 days by CloudTrail Event History)
EVENTS_DEFAULT_LOOKBACK_DAYS = 90
EVENTS_MIN_LOOKBACK_DAYS = 1
EVENTS_MAX_LOOKBACK_DAYS = 90
# Page size controls how many events CloudTrail returns (prepares for API pagination)
EVENTS_DEFAULT_PAGE_SIZE = 50
EVENTS_MIN_PAGE_SIZE = 1
EVENTS_MAX_PAGE_SIZE = 50 # CloudTrail lookup_events max is 50
# Allowed query parameters for the events endpoint
EVENTS_ALLOWED_PARAMS = frozenset(
{"lookback_days", "page[size]", "include_read_events"}
)
ordering_fields = [
"provider_uid",
"uid",
@@ -2579,6 +2600,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
def get_serializer_class(self):
if self.action in ["metadata", "metadata_latest"]:
return ResourceMetadataSerializer
if self.action == "events":
return ResourceEventSerializer
return super().get_serializer_class()
def get_filterset_class(self):
@@ -2587,8 +2610,8 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
return ResourceFilter
def filter_queryset(self, queryset):
# Do not apply filters when retrieving specific resource
if self.action == "retrieve":
# Do not apply filters when retrieving specific resource or events
if self.action in ["retrieve", "events"]:
return queryset
return super().filter_queryset(queryset)
@@ -2828,6 +2851,217 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet):
serializer.is_valid(raise_exception=True)
return Response(serializer.data)
@extend_schema(
tags=["Resource"],
summary="Get events for a resource",
description=(
"Retrieve events showing modification history for a resource. "
"Returns who modified the resource and when. Currently only available for AWS resources.\n\n"
"**Note:** Some events may not appear due to CloudTrail indexing limitations. "
"Not all AWS API calls record the resource identifier in a searchable format."
),
parameters=[
OpenApiParameter(
name="lookback_days",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Number of days to look back (default: 90, min: 1, max: 90).",
required=False,
),
OpenApiParameter(
name="page[size]",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Maximum number of events to return (default: 50, min: 1, max: 50).",
required=False,
),
OpenApiParameter(
name="include_read_events",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
description=(
"Include read-only events (Describe*, Get*, List*, etc.). "
"Default: false. Set to true to include all events."
),
required=False,
),
# NOTE: drf-spectacular auto-generates page[number] and fields[resource-events]
# parameters. This endpoint does not support pagination (results are limited by
# page[size] only) nor sparse fieldsets.
],
responses={
200: ResourceEventSerializer(many=True),
400: OpenApiResponse(description="Invalid provider or parameters"),
500: OpenApiResponse(description="Unexpected error retrieving events"),
502: OpenApiResponse(
description="Provider credentials invalid, expired, or lack required permissions"
),
503: OpenApiResponse(description="Provider service unavailable"),
},
)
@action(
detail=True,
methods=["get"],
url_name="events",
filter_backends=[], # Disable filters - we're calling external API, not filtering queryset
)
def events(self, request, pk=None):
"""Get events for a resource."""
resource = self.get_object()
# Validate query parameters - reject unknown parameters
for param in request.query_params.keys():
if param not in self.EVENTS_ALLOWED_PARAMS:
raise ValidationError(
[
{
"detail": f"invalid parameter '{param}'",
"status": "400",
"source": {"parameter": param},
"code": "invalid",
}
]
)
# Validate provider - currently only AWS CloudTrail is supported
if resource.provider.provider != Provider.ProviderChoices.AWS:
raise ValidationError(
[
{
"detail": "Events are only available for AWS resources",
"status": "400",
"source": {"pointer": "/data/attributes/provider"},
"code": "invalid_provider",
}
]
)
# Validate and parse lookback_days from query params
lookback_days_str = request.query_params.get("lookback_days")
if lookback_days_str is None:
lookback_days = self.EVENTS_DEFAULT_LOOKBACK_DAYS
else:
try:
lookback_days = int(lookback_days_str)
except (ValueError, TypeError):
raise ValidationError(
[
{
"detail": "lookback_days must be a valid integer",
"status": "400",
"source": {"parameter": "lookback_days"},
"code": "invalid",
}
]
)
if not (
self.EVENTS_MIN_LOOKBACK_DAYS
<= lookback_days
<= self.EVENTS_MAX_LOOKBACK_DAYS
):
raise ValidationError(
[
{
"detail": (
f"lookback_days must be between {self.EVENTS_MIN_LOOKBACK_DAYS} "
f"and {self.EVENTS_MAX_LOOKBACK_DAYS}"
),
"status": "400",
"source": {"parameter": "lookback_days"},
"code": "out_of_range",
}
]
)
# Validate and parse page[size] from query params (JSON:API pagination)
page_size_str = request.query_params.get("page[size]")
if page_size_str is None:
page_size = self.EVENTS_DEFAULT_PAGE_SIZE
else:
try:
page_size = int(page_size_str)
except (ValueError, TypeError):
raise ValidationError(
[
{
"detail": "page[size] must be a valid integer",
"status": "400",
"source": {"parameter": "page[size]"},
"code": "invalid",
}
]
)
if not (
self.EVENTS_MIN_PAGE_SIZE <= page_size <= self.EVENTS_MAX_PAGE_SIZE
):
raise ValidationError(
[
{
"detail": (
f"page[size] must be between {self.EVENTS_MIN_PAGE_SIZE} "
f"and {self.EVENTS_MAX_PAGE_SIZE}"
),
"status": "400",
"source": {"parameter": "page[size]"},
"code": "out_of_range",
}
]
)
# Parse include_read_events (default: false)
include_read_events = (
request.query_params.get("include_read_events", "").lower() == "true"
)
try:
# Initialize Prowler provider using existing utility
prowler_provider = initialize_prowler_provider(resource.provider)
# Get the boto3 session from the Prowler provider
session = prowler_provider._session.current_session
# Create timeline service (currently only AWS/CloudTrail is supported)
timeline_service = CloudTrailTimeline(
session=session,
lookback_days=lookback_days,
max_results=page_size,
write_events_only=not include_read_events,
)
# Get timeline events
events = timeline_service.get_resource_timeline(
region=resource.region,
resource_uid=resource.uid,
)
serializer = ResourceEventSerializer(events, many=True)
return Response(serializer.data)
except NoCredentialsError:
# 502 because this is an upstream auth failure, not API auth failure
raise UpstreamAuthenticationError(
detail="Credentials not found for this provider. Please reconnect the provider."
)
except AWSCredentialsError:
# Handles expired tokens, invalid keys, profile not found, etc.
raise UpstreamAuthenticationError()
except ClientError as e:
error_code = e.response.get("Error", {}).get("Code", "")
logger.error(
f"Provider API error retrieving events: {str(e)}",
exc_info=True,
)
# AccessDenied means the credentials don't have the required permissions
if error_code in ("AccessDenied", "AccessDeniedException"):
raise UpstreamAccessDeniedError()
raise UpstreamServiceUnavailableError()
except Exception as e:
sentry_sdk.capture_exception(e)
raise UpstreamInternalError(detail="Failed to retrieve events")
@extend_schema_view(
list=extend_schema(