diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 5bb7da487f..8f2b480a36 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -37,6 +37,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🐞 Fixed - Duplicate Kubernetes RBAC findings when the same User or Group subject appeared in multiple ClusterRoleBindings [(#10242)](https://github.com/prowler-cloud/prowler/pull/10242) +- Return a compact actor name from CloudTrail `userIdentity` events [(#10986)](https://github.com/prowler-cloud/prowler/pull/10986) --- diff --git a/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py b/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py index b73d070078..2f03dd8c84 100644 --- a/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py +++ b/prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py @@ -221,27 +221,12 @@ class CloudTrailTimeline(TimelineService): @staticmethod def _extract_actor(user_identity: Dict[str, Any]) -> str: - """Extract a human-readable actor name from CloudTrail userIdentity.""" - # Try ARN first - most reliable + """Return a compact actor name from CloudTrail userIdentity. + + For ARNs, returns the resource portion (everything after the last + `:`) — e.g. `user/alice`, `assumed-role/MyRole/session-name`, + `root`. The full ARN is preserved separately in `actor_uid`. + """ if arn := user_identity.get("arn"): - if "/" in arn: - parts = arn.split("/") - # For assumed-role, return the role name (second-to-last part) - if "assumed-role" in arn and len(parts) >= 2: - return parts[-2] - return parts[-1] - return arn.split(":")[-1] - - # Fall back to userName - if username := user_identity.get("userName"): - return username - - # Fall back to principalId - if principal_id := user_identity.get("principalId"): - return principal_id - - # For service-invoked actions - if invoking_service := user_identity.get("invokedBy"): - return invoking_service - - return "Unknown" + return arn.rsplit(":", 1)[-1] + return user_identity.get("invokedBy") or "Unknown" diff --git a/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py b/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py index aeca0f7c1d..5c2c99cbfc 100644 --- a/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py +++ b/tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py @@ -100,7 +100,7 @@ class TestCloudTrailTimeline: assert len(result) == 1 assert result[0]["event_name"] == "RunInstances" - assert result[0]["actor"] == "admin" + assert result[0]["actor"] == "user/admin" assert result[0]["source_ip_address"] == "203.0.113.1" def test_get_resource_timeline_with_resource_uid( @@ -304,14 +304,28 @@ class TestExtractActor: "arn": "arn:aws:iam::123456789012:user/alice", "userName": "alice", } - assert CloudTrailTimeline._extract_actor(user_identity) == "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) == "MyRole" + 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"} @@ -327,21 +341,33 @@ class TestExtractActor: == "elasticloadbalancing.amazonaws.com" ) - def test_extract_actor_fallback_to_principal_id(self): - user_identity = {"type": "Unknown", "principalId": "AROAEXAMPLEID:session"} - assert ( - CloudTrailTimeline._extract_actor(user_identity) == "AROAEXAMPLEID:session" - ) - 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) == "developer" + assert ( + CloudTrailTimeline._extract_actor(user_identity) + == "federated-user/developer" + ) class TestParseEvent: @@ -380,7 +406,7 @@ class TestParseEvent: assert result is not None assert result["event_name"] == "RunInstances" assert result["event_source"] == "ec2.amazonaws.com" - assert result["actor"] == "admin" + assert result["actor"] == "user/admin" assert result["actor_uid"] == "arn:aws:iam::123456789012:user/admin" assert result["actor_type"] == "IAMUser" @@ -424,7 +450,10 @@ class TestParseEvent: "EventName": "RunInstances", "EventSource": "ec2.amazonaws.com", "CloudTrailEvent": { - "userIdentity": {"type": "IAMUser", "userName": "admin"}, + "userIdentity": { + "type": "IAMUser", + "arn": "arn:aws:iam::123456789012:user/admin", + }, }, } timeline = CloudTrailTimeline(session=mock_session) @@ -432,7 +461,7 @@ class TestParseEvent: assert result is not None assert result["event_name"] == "RunInstances" - assert result["actor"] == "admin" + 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).""" @@ -506,7 +535,7 @@ class TestParseEvent: assert result is not None assert result["event_name"] == "RunInstances" - assert result["actor"] == "admin" + assert result["actor"] == "user/admin" # actor_type should be None when not present in userIdentity assert result["actor_type"] is None