mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-21 10:54:20 +00:00
794 lines
30 KiB
Python
794 lines
30 KiB
Python
import json
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from botocore.exceptions import ClientError
|
|
|
|
from prowler.providers.aws.lib.cloudtrail_timeline.cloudtrail_timeline import (
|
|
CloudTrailTimeline,
|
|
)
|
|
|
|
|
|
class TestCloudTrailTimeline:
|
|
@pytest.fixture
|
|
def mock_session(self):
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def sample_cloudtrail_event(self):
|
|
return {
|
|
"EventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "RunInstances",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{
|
|
"userIdentity": {
|
|
"type": "IAMUser",
|
|
"arn": "arn:aws:iam::123456789012:user/admin",
|
|
"userName": "admin",
|
|
},
|
|
"sourceIPAddress": "203.0.113.1",
|
|
"userAgent": "aws-cli/2.0.0",
|
|
"requestParameters": {"instanceType": "t3.micro"},
|
|
"responseElements": {
|
|
"instancesSet": {"items": [{"instanceId": "i-1234"}]}
|
|
},
|
|
}
|
|
),
|
|
}
|
|
|
|
def test_init_default_lookback(self, mock_session):
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
assert timeline._lookback_days == 90
|
|
|
|
def test_init_custom_lookback(self, mock_session):
|
|
timeline = CloudTrailTimeline(session=mock_session, lookback_days=30)
|
|
assert timeline._lookback_days == 30
|
|
|
|
def test_init_lookback_capped_at_max(self, mock_session):
|
|
timeline = CloudTrailTimeline(session=mock_session, lookback_days=365)
|
|
assert timeline._lookback_days == 90
|
|
|
|
def test_init_default_max_results(self, mock_session):
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
assert timeline._max_results == 50
|
|
|
|
def test_init_custom_max_results(self, mock_session):
|
|
timeline = CloudTrailTimeline(session=mock_session, max_results=10)
|
|
assert timeline._max_results == 10
|
|
|
|
def test_init_default_write_events_only(self, mock_session):
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
assert timeline._write_events_only is True
|
|
|
|
def test_init_write_events_only_disabled(self, mock_session):
|
|
timeline = CloudTrailTimeline(session=mock_session, write_events_only=False)
|
|
assert timeline._write_events_only is False
|
|
|
|
def test_get_resource_timeline_defaults_to_us_east_1(self, mock_session):
|
|
"""When no region is provided, should default to us-east-1 for global resources."""
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": []}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
timeline.get_resource_timeline(
|
|
resource_uid="arn:aws:iam::123456789012:user/admin"
|
|
)
|
|
|
|
# Verify us-east-1 was used as the default region
|
|
mock_session.client.assert_called_with("cloudtrail", region_name="us-east-1")
|
|
|
|
def test_get_resource_timeline_missing_identifier_raises(self, mock_session):
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
with pytest.raises(ValueError, match="Either resource_id or resource_uid"):
|
|
timeline.get_resource_timeline(region="us-east-1")
|
|
|
|
def test_get_resource_timeline_with_resource_id(
|
|
self, mock_session, sample_cloudtrail_event
|
|
):
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": [sample_cloudtrail_event]}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1", resource_id="i-1234567890abcdef0"
|
|
)
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["event_name"] == "RunInstances"
|
|
assert result[0]["actor"] == "user/admin"
|
|
assert result[0]["source_ip_address"] == "203.0.113.1"
|
|
|
|
def test_get_resource_timeline_with_resource_uid(
|
|
self, mock_session, sample_cloudtrail_event
|
|
):
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": [sample_cloudtrail_event]}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1",
|
|
resource_uid="arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
|
|
)
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["event_name"] == "RunInstances"
|
|
|
|
def test_get_resource_timeline_prefers_uid_over_id(self, mock_session):
|
|
"""When both resource_id and resource_uid are provided, UID is tried first."""
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": []}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
timeline.get_resource_timeline(
|
|
region="us-east-1",
|
|
resource_id="i-1234",
|
|
resource_uid="arn:aws:ec2:us-east-1:123:instance/i-1234",
|
|
)
|
|
|
|
# Verify UID was used on the first lookup call
|
|
first_call = mock_client.lookup_events.call_args_list[0]
|
|
lookup_attrs = first_call.kwargs["LookupAttributes"]
|
|
assert (
|
|
lookup_attrs[0]["AttributeValue"]
|
|
== "arn:aws:ec2:us-east-1:123:instance/i-1234"
|
|
)
|
|
|
|
def test_get_resource_timeline_client_error(self, mock_session):
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.side_effect = ClientError(
|
|
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
|
|
"LookupEvents",
|
|
)
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
with pytest.raises(ClientError):
|
|
timeline.get_resource_timeline(region="us-east-1", resource_id="i-1234")
|
|
|
|
def test_get_resource_timeline_multiple_events(self, mock_session):
|
|
events = [
|
|
{
|
|
"EventId": "event-1-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "RunInstances",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{
|
|
"userIdentity": {
|
|
"type": "IAMUser",
|
|
"arn": "arn:aws:iam::123456789012:user/admin",
|
|
}
|
|
}
|
|
),
|
|
},
|
|
{
|
|
"EventId": "event-2-id",
|
|
"EventTime": datetime(2024, 1, 15, 11, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "StopInstances",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{
|
|
"userIdentity": {
|
|
"type": "IAMUser",
|
|
"arn": "arn:aws:iam::123456789012:user/ops",
|
|
}
|
|
}
|
|
),
|
|
},
|
|
]
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": events}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1", resource_id="i-1234"
|
|
)
|
|
|
|
assert len(result) == 2
|
|
assert result[0]["event_name"] == "RunInstances"
|
|
assert result[1]["event_name"] == "StopInstances"
|
|
|
|
def test_get_resource_timeline_uses_max_results(self, mock_session):
|
|
"""Verify MaxResults is passed to lookup_events."""
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": []}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session, max_results=25)
|
|
timeline.get_resource_timeline(region="us-east-1", resource_id="i-1234")
|
|
|
|
# Verify MaxResults was passed to lookup_events
|
|
call_args = mock_client.lookup_events.call_args
|
|
assert call_args.kwargs["MaxResults"] == 25
|
|
|
|
def test_get_resource_timeline_filters_read_only_events(self, mock_session):
|
|
"""Verify read-only events are filtered when write_events_only=True."""
|
|
events = [
|
|
{
|
|
"EventId": "write-event-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "CreateSecurityGroup",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
|
),
|
|
},
|
|
{
|
|
"EventId": "read-event-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc),
|
|
"EventName": "DescribeSecurityGroups",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
|
),
|
|
},
|
|
{
|
|
"EventId": "another-read-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 32, 0, tzinfo=timezone.utc),
|
|
"EventName": "GetSecurityGroupRules",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
|
),
|
|
},
|
|
]
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": events}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
# Default: write_events_only=True
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1", resource_id="sg-123"
|
|
)
|
|
|
|
# Only the write event should be returned
|
|
assert len(result) == 1
|
|
assert result[0]["event_name"] == "CreateSecurityGroup"
|
|
|
|
def test_get_resource_timeline_includes_read_events_when_disabled(
|
|
self, mock_session
|
|
):
|
|
"""Verify all events returned when write_events_only=False."""
|
|
events = [
|
|
{
|
|
"EventId": "write-event-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "CreateSecurityGroup",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
|
),
|
|
},
|
|
{
|
|
"EventId": "read-event-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 31, 0, tzinfo=timezone.utc),
|
|
"EventName": "DescribeSecurityGroups",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
|
),
|
|
},
|
|
]
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": events}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
# Disable filtering
|
|
timeline = CloudTrailTimeline(session=mock_session, write_events_only=False)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1", resource_id="sg-123"
|
|
)
|
|
|
|
# All events should be returned
|
|
assert len(result) == 2
|
|
assert result[0]["event_name"] == "CreateSecurityGroup"
|
|
assert result[1]["event_name"] == "DescribeSecurityGroups"
|
|
|
|
|
|
class TestExtractActor:
|
|
def test_extract_actor_iam_user(self):
|
|
user_identity = {
|
|
"type": "IAMUser",
|
|
"arn": "arn:aws:iam::123456789012:user/alice",
|
|
"userName": "alice",
|
|
}
|
|
assert CloudTrailTimeline._extract_actor(user_identity) == "user/alice"
|
|
|
|
def test_extract_actor_assumed_role(self):
|
|
user_identity = {
|
|
"type": "AssumedRole",
|
|
"arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session-name",
|
|
}
|
|
assert (
|
|
CloudTrailTimeline._extract_actor(user_identity)
|
|
== "assumed-role/MyRole/session-name"
|
|
)
|
|
|
|
def test_extract_actor_assumed_role_sso(self):
|
|
"""SSO sessions store the user identity in the session name."""
|
|
user_identity = {
|
|
"type": "AssumedRole",
|
|
"arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com",
|
|
}
|
|
assert (
|
|
CloudTrailTimeline._extract_actor(user_identity)
|
|
== "assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com"
|
|
)
|
|
|
|
def test_extract_actor_root(self):
|
|
user_identity = {"type": "Root", "arn": "arn:aws:iam::123456789012:root"}
|
|
assert CloudTrailTimeline._extract_actor(user_identity) == "root"
|
|
|
|
def test_extract_actor_service(self):
|
|
user_identity = {
|
|
"type": "AWSService",
|
|
"invokedBy": "elasticloadbalancing.amazonaws.com",
|
|
}
|
|
assert (
|
|
CloudTrailTimeline._extract_actor(user_identity)
|
|
== "elasticloadbalancing.amazonaws.com"
|
|
)
|
|
|
|
def test_extract_actor_unknown(self):
|
|
assert CloudTrailTimeline._extract_actor({}) == "Unknown"
|
|
|
|
def test_extract_actor_username_only_returns_unknown(self):
|
|
"""When userIdentity carries only userName/principalId (no arn or
|
|
invokedBy), we deliberately return "Unknown" — we rely on the ARN
|
|
from the upstream service for the actor."""
|
|
assert (
|
|
CloudTrailTimeline._extract_actor({"type": "IAMUser", "userName": "alice"})
|
|
== "Unknown"
|
|
)
|
|
assert (
|
|
CloudTrailTimeline._extract_actor(
|
|
{"type": "Unknown", "principalId": "AROAEXAMPLEID:session"}
|
|
)
|
|
== "Unknown"
|
|
)
|
|
|
|
def test_extract_actor_federated_user(self):
|
|
user_identity = {
|
|
"type": "FederatedUser",
|
|
"arn": "arn:aws:sts::123456789012:federated-user/developer",
|
|
}
|
|
assert (
|
|
CloudTrailTimeline._extract_actor(user_identity)
|
|
== "federated-user/developer"
|
|
)
|
|
|
|
|
|
class TestParseEvent:
|
|
@pytest.fixture
|
|
def mock_session(self):
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def sample_cloudtrail_event(self):
|
|
return {
|
|
"EventId": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "RunInstances",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{
|
|
"userIdentity": {
|
|
"type": "IAMUser",
|
|
"arn": "arn:aws:iam::123456789012:user/admin",
|
|
"userName": "admin",
|
|
},
|
|
"sourceIPAddress": "203.0.113.1",
|
|
"userAgent": "aws-cli/2.0.0",
|
|
"requestParameters": {"instanceType": "t3.micro"},
|
|
"responseElements": {
|
|
"instancesSet": {"items": [{"instanceId": "i-1234"}]}
|
|
},
|
|
}
|
|
),
|
|
}
|
|
|
|
def test_parse_event_success(self, mock_session, sample_cloudtrail_event):
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline._parse_event(sample_cloudtrail_event)
|
|
|
|
assert result is not None
|
|
assert result["event_name"] == "RunInstances"
|
|
assert result["event_source"] == "ec2.amazonaws.com"
|
|
assert result["actor"] == "user/admin"
|
|
assert result["actor_uid"] == "arn:aws:iam::123456789012:user/admin"
|
|
assert result["actor_type"] == "IAMUser"
|
|
|
|
def test_parse_event_malformed_json(self, mock_session):
|
|
event = {
|
|
"EventId": "malformed-event-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "RunInstances",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": "not valid json",
|
|
}
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
assert timeline._parse_event(event) is None
|
|
|
|
def test_parse_event_with_error_fields(self, mock_session):
|
|
event = {
|
|
"EventId": "error-event-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "CreateBucket",
|
|
"EventSource": "s3.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{
|
|
"userIdentity": {"type": "IAMUser", "userName": "developer"},
|
|
"errorCode": "AccessDenied",
|
|
"errorMessage": "Access Denied",
|
|
}
|
|
),
|
|
}
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline._parse_event(event)
|
|
|
|
assert result is not None
|
|
assert result["error_code"] == "AccessDenied"
|
|
assert result["error_message"] == "Access Denied"
|
|
|
|
def test_parse_event_dict_cloud_trail_event(self, mock_session):
|
|
"""Test parsing when CloudTrailEvent is already a dict (not JSON string)."""
|
|
event = {
|
|
"EventId": "dict-event-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "RunInstances",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": {
|
|
"userIdentity": {
|
|
"type": "IAMUser",
|
|
"arn": "arn:aws:iam::123456789012:user/admin",
|
|
},
|
|
},
|
|
}
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline._parse_event(event)
|
|
|
|
assert result is not None
|
|
assert result["event_name"] == "RunInstances"
|
|
assert result["actor"] == "user/admin"
|
|
|
|
def test_parse_event_missing_event_id(self, mock_session):
|
|
"""Test parsing event without EventId returns None (event_id is required)."""
|
|
event = {
|
|
# Missing EventId
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "RunInstances",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{"userIdentity": {"type": "IAMUser", "userName": "admin"}}
|
|
),
|
|
}
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline._parse_event(event)
|
|
|
|
# Should return None because event_id is required by TimelineEvent model
|
|
assert result is None
|
|
|
|
def test_parse_event_uses_request_data_and_response_data_fields(self, mock_session):
|
|
"""Test that parsed event uses request_data and response_data field names."""
|
|
event = {
|
|
"EventId": "field-names-test-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "CreateBucket",
|
|
"EventSource": "s3.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{
|
|
"userIdentity": {"type": "IAMUser", "userName": "admin"},
|
|
"requestParameters": {"bucketName": "my-bucket", "acl": "private"},
|
|
"responseElements": {
|
|
"location": "http://my-bucket.s3.amazonaws.com"
|
|
},
|
|
}
|
|
),
|
|
}
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline._parse_event(event)
|
|
|
|
assert result is not None
|
|
# Verify field names are request_data/response_data (not request_parameters/response_elements)
|
|
assert "request_data" in result
|
|
assert "response_data" in result
|
|
assert "request_parameters" not in result
|
|
assert "response_elements" not in result
|
|
# Verify the data is correctly mapped
|
|
assert result["request_data"] == {"bucketName": "my-bucket", "acl": "private"}
|
|
assert result["response_data"] == {
|
|
"location": "http://my-bucket.s3.amazonaws.com"
|
|
}
|
|
|
|
def test_parse_event_missing_actor_type(self, mock_session):
|
|
"""Test parsing event where userIdentity has no type field."""
|
|
event = {
|
|
"EventId": "no-actor-type-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "RunInstances",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{
|
|
"userIdentity": {
|
|
# No "type" field
|
|
"arn": "arn:aws:iam::123456789012:user/admin",
|
|
"userName": "admin",
|
|
},
|
|
"sourceIPAddress": "203.0.113.1",
|
|
}
|
|
),
|
|
}
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline._parse_event(event)
|
|
|
|
assert result is not None
|
|
assert result["event_name"] == "RunInstances"
|
|
assert result["actor"] == "user/admin"
|
|
# actor_type should be None when not present in userIdentity
|
|
assert result["actor_type"] is None
|
|
|
|
def test_parse_event_empty_request_response(self, mock_session):
|
|
"""Test parsing event with no requestParameters or responseElements."""
|
|
event = {
|
|
"EventId": "no-params-id",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "DescribeInstances",
|
|
"EventSource": "ec2.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{
|
|
"userIdentity": {"type": "IAMUser", "userName": "reader"},
|
|
# No requestParameters or responseElements
|
|
}
|
|
),
|
|
}
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline._parse_event(event)
|
|
|
|
assert result is not None
|
|
assert result["request_data"] is None
|
|
assert result["response_data"] is None
|
|
|
|
|
|
class TestClientCaching:
|
|
def test_client_cached_per_region(self):
|
|
mock_session = MagicMock()
|
|
mock_client = MagicMock()
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
|
|
# Get client twice for same region
|
|
client1 = timeline._get_client("us-east-1")
|
|
client2 = timeline._get_client("us-east-1")
|
|
|
|
# Should only create client once
|
|
assert mock_session.client.call_count == 1
|
|
assert client1 is client2
|
|
|
|
def test_different_clients_per_region(self):
|
|
mock_session = MagicMock()
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
|
|
timeline._get_client("us-east-1")
|
|
timeline._get_client("eu-west-1")
|
|
|
|
# Should create client for each region
|
|
assert mock_session.client.call_count == 2
|
|
|
|
|
|
class TestIsReadOnlyEvent:
|
|
"""Tests for _is_read_only_event method."""
|
|
|
|
@pytest.fixture
|
|
def mock_session(self):
|
|
return MagicMock()
|
|
|
|
@pytest.mark.parametrize(
|
|
"event_name",
|
|
[
|
|
"DescribeSecurityGroups",
|
|
"GetBucketPolicy",
|
|
"ListBuckets",
|
|
"HeadObject",
|
|
"CheckAccessNotGranted",
|
|
"LookupEvents",
|
|
"SearchResources",
|
|
"ScanOnDemand",
|
|
"QueryObjects",
|
|
"BatchGetItem",
|
|
"SelectObjectContent",
|
|
],
|
|
)
|
|
def test_read_only_events_detected(self, mock_session, event_name):
|
|
"""Verify various read-only event prefixes are correctly identified."""
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
assert timeline._is_read_only_event(event_name) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"event_name",
|
|
[
|
|
"CreateSecurityGroup",
|
|
"DeleteSecurityGroup",
|
|
"ModifySecurityGroupRules",
|
|
"PutBucketPolicy",
|
|
"RunInstances",
|
|
"TerminateInstances",
|
|
"UpdateFunction",
|
|
"AttachRolePolicy",
|
|
"AuthorizeSecurityGroupIngress",
|
|
],
|
|
)
|
|
def test_write_events_not_filtered(self, mock_session, event_name):
|
|
"""Verify write events are not marked as read-only."""
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
assert timeline._is_read_only_event(event_name) is False
|
|
|
|
|
|
class TestExtractShortName:
|
|
"""Tests for _extract_short_name static method."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"identifier,expected",
|
|
[
|
|
("arn:aws:s3:::my-bucket", "my-bucket"),
|
|
("arn:aws:iam::123456789012:user/alice", "alice"),
|
|
("arn:aws:iam::123456789012:role/MyRole", "MyRole"),
|
|
(
|
|
"arn:aws:ec2:us-east-1:123456789012:instance/i-0abc1234",
|
|
"i-0abc1234",
|
|
),
|
|
(
|
|
"arn:aws:lambda:us-east-1:123456789012:function:my-func",
|
|
"my-func",
|
|
),
|
|
("arn:aws:rds:us-east-1:123456789012:db:mydb", "mydb"),
|
|
("arn:aws:dynamodb:us-east-1:123456789012:table/MyTable", "MyTable"),
|
|
(
|
|
"arn:aws:kms:us-east-1:123456789012:key/abcd-efgh",
|
|
"abcd-efgh",
|
|
),
|
|
("i-0abc1234", "i-0abc1234"),
|
|
("my-bucket", "my-bucket"),
|
|
("", ""),
|
|
],
|
|
)
|
|
def test_extract_short_name(self, identifier, expected):
|
|
assert CloudTrailTimeline._extract_short_name(identifier) == expected
|
|
|
|
|
|
class TestLookupEventsFallback:
|
|
"""Tests for the ARN-to-short-name fallback in _lookup_events."""
|
|
|
|
@pytest.fixture
|
|
def mock_session(self):
|
|
return MagicMock()
|
|
|
|
@pytest.fixture
|
|
def sample_event(self):
|
|
return {
|
|
"EventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
"EventTime": datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc),
|
|
"EventName": "CreateBucket",
|
|
"EventSource": "s3.amazonaws.com",
|
|
"CloudTrailEvent": json.dumps(
|
|
{
|
|
"userIdentity": {
|
|
"type": "IAMUser",
|
|
"arn": "arn:aws:iam::123456789012:user/admin",
|
|
"userName": "admin",
|
|
}
|
|
}
|
|
),
|
|
}
|
|
|
|
def test_no_fallback_when_arn_returns_events(self, mock_session, sample_event):
|
|
"""When the ARN lookup returns events, we do not retry with the short name."""
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": [sample_event]}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1",
|
|
resource_uid="arn:aws:kms:us-east-1:123456789012:key/abcd-efgh",
|
|
)
|
|
|
|
assert len(result) == 1
|
|
assert mock_client.lookup_events.call_count == 1
|
|
call = mock_client.lookup_events.call_args
|
|
assert (
|
|
call.kwargs["LookupAttributes"][0]["AttributeValue"]
|
|
== "arn:aws:kms:us-east-1:123456789012:key/abcd-efgh"
|
|
)
|
|
|
|
def test_fallback_to_short_name_when_arn_returns_empty(
|
|
self, mock_session, sample_event
|
|
):
|
|
"""When the ARN lookup returns nothing, we retry with the short name."""
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.side_effect = [
|
|
{"Events": []},
|
|
{"Events": [sample_event]},
|
|
]
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1", resource_uid="arn:aws:s3:::my-bucket"
|
|
)
|
|
|
|
assert len(result) == 1
|
|
assert mock_client.lookup_events.call_count == 2
|
|
first_call, second_call = mock_client.lookup_events.call_args_list
|
|
assert (
|
|
first_call.kwargs["LookupAttributes"][0]["AttributeValue"]
|
|
== "arn:aws:s3:::my-bucket"
|
|
)
|
|
assert (
|
|
second_call.kwargs["LookupAttributes"][0]["AttributeValue"] == "my-bucket"
|
|
)
|
|
|
|
def test_no_fallback_when_identifier_has_no_short_name(self, mock_session):
|
|
"""A non-ARN identifier collapses to itself; no retry should fire."""
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": []}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1", resource_id="i-0abc1234"
|
|
)
|
|
|
|
assert result == []
|
|
assert mock_client.lookup_events.call_count == 1
|
|
|
|
def test_no_fallback_when_identifier_is_not_arn(self, mock_session):
|
|
"""A non-ARN identifier with / or : must not trigger the retry."""
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": []}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1", resource_id="some-prefix/weird:value"
|
|
)
|
|
|
|
assert result == []
|
|
assert mock_client.lookup_events.call_count == 1
|
|
|
|
def test_both_lookups_empty_returns_empty_list(self, mock_session):
|
|
"""If both the ARN and short-name lookups return empty, we return []."""
|
|
mock_client = MagicMock()
|
|
mock_client.lookup_events.return_value = {"Events": []}
|
|
mock_session.client.return_value = mock_client
|
|
|
|
timeline = CloudTrailTimeline(session=mock_session)
|
|
result = timeline.get_resource_timeline(
|
|
region="us-east-1",
|
|
resource_uid="arn:aws:ec2:us-east-1:123456789012:instance/i-0abc1234",
|
|
)
|
|
|
|
assert result == []
|
|
assert mock_client.lookup_events.call_count == 2
|
|
first_call, second_call = mock_client.lookup_events.call_args_list
|
|
assert (
|
|
first_call.kwargs["LookupAttributes"][0]["AttributeValue"]
|
|
== "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc1234"
|
|
)
|
|
assert (
|
|
second_call.kwargs["LookupAttributes"][0]["AttributeValue"] == "i-0abc1234"
|
|
)
|