mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 01:15:31 +00:00
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:
17 files changed
+2370
-28
No files matched your search
@@ -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,
|
||||
}
|
||||
]
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user