Skip to content

Commit e4da974

Browse files
fix(timeline): Return a compact actor name from CloudTrail events (#10987)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
1 parent 35e867e commit e4da974

3 files changed

Lines changed: 52 additions & 37 deletions

File tree

prowler/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
77
### 🐞 Fixed
88

99
- Duplicate Kubernetes RBAC findings when the same User or Group subject appeared in multiple ClusterRoleBindings [(#10242)](https://github.com/prowler-cloud/prowler/pull/10242)
10+
- Return a compact actor name from CloudTrail `userIdentity` events [(#10986)](https://github.com/prowler-cloud/prowler/pull/10986)
1011

1112
---
1213

prowler/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline.py

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -221,27 +221,12 @@ def _parse_event(self, raw_event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
221221

222222
@staticmethod
223223
def _extract_actor(user_identity: Dict[str, Any]) -> str:
224-
"""Extract a human-readable actor name from CloudTrail userIdentity."""
225-
# Try ARN first - most reliable
224+
"""Return a compact actor name from CloudTrail userIdentity.
225+
226+
For ARNs, returns the resource portion (everything after the last
227+
`:`) — e.g. `user/alice`, `assumed-role/MyRole/session-name`,
228+
`root`. The full ARN is preserved separately in `actor_uid`.
229+
"""
226230
if arn := user_identity.get("arn"):
227-
if "/" in arn:
228-
parts = arn.split("/")
229-
# For assumed-role, return the role name (second-to-last part)
230-
if "assumed-role" in arn and len(parts) >= 2:
231-
return parts[-2]
232-
return parts[-1]
233-
return arn.split(":")[-1]
234-
235-
# Fall back to userName
236-
if username := user_identity.get("userName"):
237-
return username
238-
239-
# Fall back to principalId
240-
if principal_id := user_identity.get("principalId"):
241-
return principal_id
242-
243-
# For service-invoked actions
244-
if invoking_service := user_identity.get("invokedBy"):
245-
return invoking_service
246-
247-
return "Unknown"
231+
return arn.rsplit(":", 1)[-1]
232+
return user_identity.get("invokedBy") or "Unknown"

tests/providers/aws/lib/cloudtrail_timeline/cloudtrail_timeline_test.py

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def test_get_resource_timeline_with_resource_id(
100100

101101
assert len(result) == 1
102102
assert result[0]["event_name"] == "RunInstances"
103-
assert result[0]["actor"] == "admin"
103+
assert result[0]["actor"] == "user/admin"
104104
assert result[0]["source_ip_address"] == "203.0.113.1"
105105

106106
def test_get_resource_timeline_with_resource_uid(
@@ -304,14 +304,28 @@ def test_extract_actor_iam_user(self):
304304
"arn": "arn:aws:iam::123456789012:user/alice",
305305
"userName": "alice",
306306
}
307-
assert CloudTrailTimeline._extract_actor(user_identity) == "alice"
307+
assert CloudTrailTimeline._extract_actor(user_identity) == "user/alice"
308308

309309
def test_extract_actor_assumed_role(self):
310310
user_identity = {
311311
"type": "AssumedRole",
312312
"arn": "arn:aws:sts::123456789012:assumed-role/MyRole/session-name",
313313
}
314-
assert CloudTrailTimeline._extract_actor(user_identity) == "MyRole"
314+
assert (
315+
CloudTrailTimeline._extract_actor(user_identity)
316+
== "assumed-role/MyRole/session-name"
317+
)
318+
319+
def test_extract_actor_assumed_role_sso(self):
320+
"""SSO sessions store the user identity in the session name."""
321+
user_identity = {
322+
"type": "AssumedRole",
323+
"arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com",
324+
}
325+
assert (
326+
CloudTrailTimeline._extract_actor(user_identity)
327+
== "assumed-role/AWSReservedSSO_AdministratorAccess_abcdef1234567890/user@example.com"
328+
)
315329

316330
def test_extract_actor_root(self):
317331
user_identity = {"type": "Root", "arn": "arn:aws:iam::123456789012:root"}
@@ -327,21 +341,33 @@ def test_extract_actor_service(self):
327341
== "elasticloadbalancing.amazonaws.com"
328342
)
329343

330-
def test_extract_actor_fallback_to_principal_id(self):
331-
user_identity = {"type": "Unknown", "principalId": "AROAEXAMPLEID:session"}
332-
assert (
333-
CloudTrailTimeline._extract_actor(user_identity) == "AROAEXAMPLEID:session"
334-
)
335-
336344
def test_extract_actor_unknown(self):
337345
assert CloudTrailTimeline._extract_actor({}) == "Unknown"
338346

347+
def test_extract_actor_username_only_returns_unknown(self):
348+
"""When userIdentity carries only userName/principalId (no arn or
349+
invokedBy), we deliberately return "Unknown" — we rely on the ARN
350+
from the upstream service for the actor."""
351+
assert (
352+
CloudTrailTimeline._extract_actor({"type": "IAMUser", "userName": "alice"})
353+
== "Unknown"
354+
)
355+
assert (
356+
CloudTrailTimeline._extract_actor(
357+
{"type": "Unknown", "principalId": "AROAEXAMPLEID:session"}
358+
)
359+
== "Unknown"
360+
)
361+
339362
def test_extract_actor_federated_user(self):
340363
user_identity = {
341364
"type": "FederatedUser",
342365
"arn": "arn:aws:sts::123456789012:federated-user/developer",
343366
}
344-
assert CloudTrailTimeline._extract_actor(user_identity) == "developer"
367+
assert (
368+
CloudTrailTimeline._extract_actor(user_identity)
369+
== "federated-user/developer"
370+
)
345371

346372

347373
class TestParseEvent:
@@ -380,7 +406,7 @@ def test_parse_event_success(self, mock_session, sample_cloudtrail_event):
380406
assert result is not None
381407
assert result["event_name"] == "RunInstances"
382408
assert result["event_source"] == "ec2.amazonaws.com"
383-
assert result["actor"] == "admin"
409+
assert result["actor"] == "user/admin"
384410
assert result["actor_uid"] == "arn:aws:iam::123456789012:user/admin"
385411
assert result["actor_type"] == "IAMUser"
386412

@@ -424,15 +450,18 @@ def test_parse_event_dict_cloud_trail_event(self, mock_session):
424450
"EventName": "RunInstances",
425451
"EventSource": "ec2.amazonaws.com",
426452
"CloudTrailEvent": {
427-
"userIdentity": {"type": "IAMUser", "userName": "admin"},
453+
"userIdentity": {
454+
"type": "IAMUser",
455+
"arn": "arn:aws:iam::123456789012:user/admin",
456+
},
428457
},
429458
}
430459
timeline = CloudTrailTimeline(session=mock_session)
431460
result = timeline._parse_event(event)
432461

433462
assert result is not None
434463
assert result["event_name"] == "RunInstances"
435-
assert result["actor"] == "admin"
464+
assert result["actor"] == "user/admin"
436465

437466
def test_parse_event_missing_event_id(self, mock_session):
438467
"""Test parsing event without EventId returns None (event_id is required)."""
@@ -506,7 +535,7 @@ def test_parse_event_missing_actor_type(self, mock_session):
506535

507536
assert result is not None
508537
assert result["event_name"] == "RunInstances"
509-
assert result["actor"] == "admin"
538+
assert result["actor"] == "user/admin"
510539
# actor_type should be None when not present in userIdentity
511540
assert result["actor_type"] is None
512541

0 commit comments

Comments
 (0)