AWSSplunkSentinel Updated October 2026

Five CloudTrail detections to deploy first

A new AWS account produces a lot of CloudTrail and very little signal. These are the five detections I would put in place first. Each one is cheap to run, maps to a technique attackers actually use, and also satisfies a control an auditor will ask about.

The Splunk queries assume the Splunk Add-on for AWS (sourcetype=aws:cloudtrail) and management events from every account and region. Replace <aws_index> with your own index.

# Detection ATT&CK NIST 800-53
1 Logging or monitoring disabled T1562.008 AU-9
2 Root user activity T1078.004 AC-6
3 Console login without MFA T1078.004 IA-2(1)
4 Credentials created for another user T1098.001 AC-2
5 Resource opened to the internet T1562.007 SC-7

First: is the log source alive?

A detection that never fires looks exactly like a quiet environment. Before trusting any of the queries below, alert on the feed itself going silent.

| tstats latest(_time) as last_event where index=<aws_index> by sourcetype, source
| eval minutes_silent = round((now() - last_event) / 60)
| where minutes_silent > 60

This only reports sources that sent something inside the search window. A source that has been silent for longer than the window disappears from the results, so compare against a lookup of the accounts you expect to see.

1. Logging or monitoring disabled

Turning off the trail is one of the first things an attacker with enough privilege will do, and it is rare in normal operations.

index=<aws_index> sourcetype=aws:cloudtrail NOT errorCode=*
    ( (eventSource=cloudtrail.amazonaws.com eventName IN (StopLogging, DeleteTrail, UpdateTrail, PutEventSelectors))
   OR (eventSource=guardduty.amazonaws.com eventName IN (DeleteDetector, UpdateDetector))
   OR (eventSource=config.amazonaws.com eventName IN (StopConfigurationRecorder, DeleteConfigurationRecorder, DeleteDeliveryChannel))
   OR (eventSource=ec2.amazonaws.com eventName=DeleteFlowLogs) )
| stats count min(_time) as first_seen max(_time) as last_seen values(eventName) as actions values(sourceIPAddress) as src_ip
    by userIdentity.arn, recipientAccountId, awsRegion
| convert ctime(first_seen) ctime(last_seen)

The same logic in Microsoft Sentinel, using the AWS connector's AWSCloudTrail table:

AWSCloudTrail
| where TimeGenerated > ago(1h)
| where isempty(ErrorCode)
| where (EventSource == "cloudtrail.amazonaws.com" and EventName in ("StopLogging", "DeleteTrail", "UpdateTrail", "PutEventSelectors"))
     or (EventSource == "guardduty.amazonaws.com" and EventName in ("DeleteDetector", "UpdateDetector"))
     or (EventSource == "config.amazonaws.com" and EventName in ("StopConfigurationRecorder", "DeleteConfigurationRecorder", "DeleteDeliveryChannel"))
     or (EventSource == "ec2.amazonaws.com" and EventName == "DeleteFlowLogs")
| project TimeGenerated, RecipientAccountId, AWSRegion, UserIdentityArn, EventName, SourceIpAddress, UserAgent

Tuning. UpdateTrail and PutEventSelectors are the noisy ones, because infrastructure-as-code pipelines call them on every deploy. Allowlist the pipeline role rather than dropping the event names. Removing the errorCode filter also shows denied attempts, which are worth a lower-severity alert of their own.

2. Root user activity

The root user should be used for a handful of account-level tasks and nothing else. Any other activity deserves a look.

index=<aws_index> sourcetype=aws:cloudtrail userIdentity.type=Root
    eventType!=AwsServiceEvent NOT userIdentity.invokedBy=*
| stats count min(_time) as first_seen values(eventName) as actions values(sourceIPAddress) as src_ip values(userAgent) as user_agent
    by recipientAccountId
| convert ctime(first_seen)

Tuning. The two exclusions remove events AWS services generate on the account's behalf, which are logged with a root identity but are not a person signing in. What remains should be close to zero. If it is not, that is a finding in itself.

3. Console login without MFA

index=<aws_index> sourcetype=aws:cloudtrail eventName=ConsoleLogin userIdentity.type=IAMUser
    "responseElements.ConsoleLogin"=Success "additionalEventData.MFAUsed"=No
| stats count min(_time) as first_seen values(sourceIPAddress) as src_ip
    by userIdentity.arn, recipientAccountId
| convert ctime(first_seen)

Tuning. The restriction to IAMUser matters. Federated and IAM Identity Center sign-ins report MFAUsed=No because MFA happened at the identity provider, not at AWS. Without the filter, every single sign-on login looks like a violation. Verify MFA for those users in the identity provider's own logs.

4. Credentials created for another user

Creating an access key or console password for a different user is a standard way to keep access after the original foothold is closed.

index=<aws_index> sourcetype=aws:cloudtrail eventSource=iam.amazonaws.com NOT errorCode=*
    eventName IN (CreateAccessKey, CreateLoginProfile, UpdateLoginProfile)
| rename requestParameters.userName as target_user, userIdentity.userName as actor_user
| where isnotnull(target_user) AND (isnull(actor_user) OR target_user!=actor_user)
| table _time, recipientAccountId, userIdentity.arn, eventName, target_user, sourceIPAddress, userAgent

Tuning. A user rotating their own key is excluded. Anything done through an assumed role is included, because a role session has no userName to compare against. That will catch your provisioning automation, so allowlist those role ARNs explicitly and keep the list short.

5. Resource opened to the internet

index=<aws_index> sourcetype=aws:cloudtrail NOT errorCode=*
    ( (eventName=AuthorizeSecurityGroupIngress ("0.0.0.0/0" OR "::/0"))
   OR eventName IN (DeleteBucketPublicAccessBlock, DeleteAccountPublicAccessBlock) )
| table _time, recipientAccountId, awsRegion, userIdentity.arn, eventName, requestParameters.groupId, requestParameters.bucketName, sourceIPAddress

Tuning. A security group rule open to the world on 443 for a public load balancer is expected. The same rule on 22, 3389 or a database port is not. Once the base alert is stable, split it by port and raise the severity for administrative and database ports. PutBucketPolicy and PutBucketAcl are deliberately left out, because they fire on every policy change. Treat them as enrichment when a public access block is removed.

What comes next

Once these five are quiet and trusted, the next layer is behavior rather than single events: a principal generating a burst of AccessDenied errors across many API calls, an access key used from a new network, or a role assumed from an account you do not own. Those need baselines, which is why they come second.

These queries are starting points. Field names depend on your add-on version and how the data was onboarded, so run each one against your own data before enabling it as an alert.

← All writing